From 4cf93c171ea64bdaf404178fe6b81afb4f254a4f Mon Sep 17 00:00:00 2001 From: Luke Emery-Fertitta Date: Sat, 23 Dec 2023 08:20:15 -0800 Subject: [PATCH] Support for all boards, minor UI improvements --- app.py | 120 ++++++++++++++++++------------- scripts/db_sync.sh | 2 +- static/js/boardSelection.js | 91 ++++++++++++----------- static/js/results.js | 115 +++++++++++++++++------------ templates/boardSelection.html.j2 | 13 ++-- templates/results.html.j2 | 6 +- 6 files changed, 200 insertions(+), 147 deletions(-) diff --git a/app.py b/app.py index 8b8008e..ffffcff 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,9 @@ +import boardlib.api.aurora import flask import sqlite3 app = flask.Flask(__name__) -DATABASE = "data/kilter/db.sqlite3" - ANGLES_SQL = """ SELECT angle @@ -136,66 +135,50 @@ """ -def get_db(): +def get_db(board_name): db = getattr(flask.g, "_database", None) if db is None: - db = flask.g._database = sqlite3.connect(DATABASE) + db = flask.g._database = sqlite3.connect(f"data/{board_name}/db.sqlite3") return db -@app.route("/api/v1/layouts") -def layouts(): - database = get_db() +@app.route("/api/v1//layouts") +def layouts(board_name): + database = get_db(board_name) cursor = database.cursor() cursor.execute(LAYOUTS_SQL) return flask.jsonify(cursor.fetchall()) -@app.route("/api/v1/layouts//sizes") -def sizes(layout_id): - database = get_db() +@app.route("/api/v1//layouts//sizes") +def sizes(board_name, layout_id): + database = get_db(board_name) cursor = database.cursor() cursor.execute(SIZES_SQL, {"layout_id": layout_id}) return flask.jsonify(cursor.fetchall()) -@app.route("/api/v1/layouts//sizes//sets") -def sets(layout_id, size_id): - database = get_db() +@app.route("/api/v1//layouts//sizes//sets") +def sets(board_name, layout_id, size_id): + database = get_db(board_name) cursor = database.cursor() cursor.execute(SETS_SQL, {"layout_id": layout_id, "size_id": size_id}) return flask.jsonify(cursor.fetchall()) -@app.route("/api/v1/search") -def search(): - sql = SEARCH_SQL - binds = { - "layout_id": flask.request.args.get("layout"), - "size_id": flask.request.args.get("size"), - "min_ascents": flask.request.args.get("minAscents"), - "min_grade": flask.request.args.get("minGrade"), - "max_grade": flask.request.args.get("maxGrade"), - "min_rating": flask.request.args.get("minRating"), - } +@app.route("/api/v1/search/count") +def resultsCount(): + base_sql, binds = get_search_base_sql_and_binds() + database = get_db(flask.request.args.get("board")) + cursor = database.cursor() + cursor.execute(f"SELECT COUNT(*) FROM ({base_sql})", binds) + results = cursor.fetchall() + return flask.jsonify(results[0][0]) - angle = flask.request.args.get("angle") - if angle and angle != "any": - sql += " AND climb_stats.angle = $angle" - binds["angle"] = angle - holds = flask.request.args.get("holds") - if holds: - sql += " AND climbs.frames LIKE $like_string" - like_string_center = "%".join( - sorted( - f"p{hold_string}" - for hold_string in holds.split("p") - if len(hold_string) > 0 - ) - ) - like_string = f"%{like_string_center}%" - binds["like_string"] = like_string +@app.route("/api/v1/search") +def search(): + base_sql, binds = get_search_base_sql_and_binds() order_by_sql_name = { "ascents": "climb_stats.ascensionist_count", @@ -204,14 +187,15 @@ def search(): "quality": "climb_stats.quality_average", }[flask.request.args.get("sortBy")] sort_order = "ASC" if flask.request.args.get("sortOrder") == "asc" else "DESC" - sql += f" ORDER BY {order_by_sql_name} {sort_order}" - sql += " LIMIT $limit OFFSET $offset" + ordered_sql = f"{base_sql} ORDER BY {order_by_sql_name} {sort_order}" + + limited_sql = f"{ordered_sql} LIMIT $limit OFFSET $offset" binds["limit"] = int(flask.request.args.get("pageSize", 10)) binds["offset"] = int(flask.request.args.get("page", 0)) * int(binds["limit"]) - database = get_db() + + database = get_db(flask.request.args.get("board")) cursor = database.cursor() - cursor.connection.set_trace_callback(print) - cursor.execute(sql, binds) + cursor.execute(limited_sql, binds) results = cursor.fetchall() return flask.jsonify(results) @@ -229,7 +213,7 @@ def filter(): layout_id = flask.request.args.get("layout") size_id = flask.request.args.get("size") set_ids = flask.request.args.getlist("set") - database = get_db() + database = get_db(board_name) cursor = database.cursor() cursor.execute(ANGLES_SQL, {"layout_id": layout_id}) angles = cursor.fetchall() @@ -244,7 +228,7 @@ def filter(): angles=angles, grades=grades, colors=colors, - **get_draw_board_args(cursor, layout_id, size_id, set_ids), + **get_draw_board_args(cursor, board_name, layout_id, size_id, set_ids), ) @@ -254,15 +238,17 @@ def results(): layout_id = flask.request.args.get("layout") size_id = flask.request.args.get("size") set_ids = flask.request.args.getlist("set") - database = get_db() + database = get_db(board_name) cursor = database.cursor() cursor.execute(COLORS_SQL, {"layout_id": layout_id}) colors = cursor.fetchall() return flask.render_template( "results.html.j2", + app_url=boardlib.api.aurora.WEB_HOSTS[board_name], colors=colors, **get_draw_board_args( cursor, + board_name, layout_id, size_id, set_ids, @@ -270,14 +256,16 @@ def results(): ) -def get_draw_board_args(cursor, layout_id, size_id, set_ids): +def get_draw_board_args(cursor, board_name, layout_id, size_id, set_ids): images_to_holds = {} for set_id in set_ids: cursor.execute( IMAGE_FILENAME_SQL, {"layout_id": layout_id, "size_id": size_id, "set_id": set_id}, ) - image_filename = f"http://api.kilterboardapp.com/img/{cursor.fetchone()[0]}" + image_filename = ( + f"{boardlib.api.aurora.API_HOSTS[board_name]}/img/{cursor.fetchone()[0]}" + ) cursor.execute(HOLDS_SQL, {"layout_id": layout_id, "set_id": set_id}) holds = cursor.fetchall() images_to_holds[image_filename] = holds @@ -291,3 +279,35 @@ def get_draw_board_args(cursor, layout_id, size_id, set_ids): "edge_bottom": size_dimensions[2], "edge_top": size_dimensions[3], } + + +def get_search_base_sql_and_binds(): + sql = SEARCH_SQL + binds = { + "layout_id": flask.request.args.get("layout"), + "size_id": flask.request.args.get("size"), + "min_ascents": flask.request.args.get("minAscents"), + "min_grade": flask.request.args.get("minGrade"), + "max_grade": flask.request.args.get("maxGrade"), + "min_rating": flask.request.args.get("minRating"), + } + + angle = flask.request.args.get("angle") + if angle and angle != "any": + sql += " AND climb_stats.angle = $angle" + binds["angle"] = angle + + holds = flask.request.args.get("holds") + if holds: + sql += " AND climbs.frames LIKE $like_string" + like_string_center = "%".join( + sorted( + f"p{hold_string}" + for hold_string in holds.split("p") + if len(hold_string) > 0 + ) + ) + like_string = f"%{like_string_center}%" + binds["like_string"] = like_string + + return sql, binds diff --git a/scripts/db_sync.sh b/scripts/db_sync.sh index a21dccc..d6797c7 100755 --- a/scripts/db_sync.sh +++ b/scripts/db_sync.sh @@ -1,4 +1,4 @@ -for BOARD in kilter tension +for BOARD in decoy grasshopper kilter tension touchstone do mkdir -p data/$BOARD boardlib database $BOARD data/$BOARD/db.sqlite3 diff --git a/static/js/boardSelection.js b/static/js/boardSelection.js index 0545e09..1e0c7ac 100644 --- a/static/js/boardSelection.js +++ b/static/js/boardSelection.js @@ -1,7 +1,8 @@ -function populateLayouts() { - fetch("/api/v1/layouts").then((response) => { +function populateLayouts(boardName) { + fetch(`/api/v1/${boardName}/layouts`).then((response) => { response.json().then((layouts) => { const layoutSelect = document.getElementById("select-layout"); + layoutSelect.innerHTML = ""; for (const [layoutId, layoutName] of layouts) { let option = document.createElement("option"); option.text = layoutName; @@ -9,15 +10,15 @@ function populateLayouts() { layoutSelect.appendChild(option); } layoutSelect.addEventListener("change", function (event) { - populateSizes(event.target.value); + populateSizes(boardName, event.target.value); }); - populateSizes(layoutSelect.value); + populateSizes(boardName, layoutSelect.value); }); }); } -function populateSizes(layoutId) { - fetch(`/api/v1/layouts/${layoutId}/sizes`).then((response) => { +function populateSizes(boardName, layoutId) { + fetch(`/api/v1/${boardName}/layouts/${layoutId}/sizes`).then((response) => { response.json().then((sizes) => { const sizeSelect = document.getElementById("select-size"); sizeSelect.innerHTML = ""; @@ -28,50 +29,51 @@ function populateSizes(layoutId) { sizeSelect.appendChild(option); } sizeSelect.addEventListener("change", function (event) { - populateSets(layoutId, event.target.value); + populateSets(boardName, layoutId, event.target.value); }); - populateSets(layoutId, sizeSelect.value); + populateSets(boardName, layoutId, sizeSelect.value); }); }); } -function populateSets(layoutId, productSizeId) { - fetch(`/api/v1/layouts/${layoutId}/sizes/${productSizeId}/sets`).then( - (response) => { - response.json().then((sets) => { - const setsDiv = document.getElementById("div-sets"); - setsDiv.innerHTML = ""; - for (const [setId, setName] of sets) { - const inputGroupDiv = document.createElement("div"); - inputGroupDiv.className = "input-group mb-3"; - setsDiv.appendChild(inputGroupDiv); +function populateSets(boardName, layoutId, productSizeId) { + fetch( + `/api/v1/${boardName}/layouts/${layoutId}/sizes/${productSizeId}/sets` + ).then((response) => { + response.json().then((sets) => { + const setsDiv = document.getElementById("div-sets"); + setsDiv.innerHTML = ""; + for (const [setId, setName] of sets) { + const inputGroupDiv = document.createElement("div"); + inputGroupDiv.className = "input-group mb-3"; + setsDiv.appendChild(inputGroupDiv); - const span = document.createElement("span"); - span.className = "input-group-text"; - span.textContent = setName; - inputGroupDiv.appendChild(span); + const span = document.createElement("span"); + span.className = "input-group-text"; + span.textContent = setName; + inputGroupDiv.appendChild(span); - const select = document.createElement("select"); - select.className = "form-select"; - select.setAttribute("data-set-id", setId); - select.addEventListener("change", updateSetsInput); - inputGroupDiv.appendChild(select); + const select = document.createElement("select"); + select.className = "form-select"; + select.setAttribute("data-set-id", setId); + select.addEventListener("change", updateSetsInput); + inputGroupDiv.appendChild(select); - const optionEnabled = document.createElement("option"); - optionEnabled.text = "Enabled"; - optionEnabled.value = true; - optionEnabled.selected = true; - select.appendChild(optionEnabled); + const optionEnabled = document.createElement("option"); + optionEnabled.text = "Enabled"; + optionEnabled.value = true; + optionEnabled.selected = true; + select.appendChild(optionEnabled); - const optionDisabled = document.createElement("option"); - optionDisabled.text = "Disabled"; - optionDisabled.value = false; - select.appendChild(optionDisabled); - } - updateSetsInput(); - }); - } - ); + const optionDisabled = document.createElement("option"); + optionDisabled.text = "Disabled (coming soon)"; + optionDisabled.value = false; + optionDisabled.disabled = true; + select.appendChild(optionDisabled); + } + updateSetsInput(); + }); + }); } function updateSetsInput() { @@ -92,4 +94,9 @@ function updateSetsInput() { document.getElementById("button-next").disabled = !isOneSetEnabled; } -populateLayouts(); +const boardSelect = document.getElementById("select-board"); +boardSelect.addEventListener("change", function (event) { + populateLayouts(event.target.value); +}); + +populateLayouts(boardSelect.value); diff --git a/static/js/results.js b/static/js/results.js index 00fef9c..51353bd 100644 --- a/static/js/results.js +++ b/static/js/results.js @@ -1,4 +1,4 @@ -function drawClimb(uuid, name, frames) { +function drawClimb(uuid, name, frames, setter, difficultyAngleText) { document .getElementById("svg-climb") .querySelectorAll('circle[stroke-opacity="1"]') @@ -22,62 +22,87 @@ function drawClimb(uuid, name, frames) { const anchor = document.createElement("a"); anchor.textContent = name; - anchor.href = `https://kilterboardapp.com/climbs/${uuid}`; + anchor.href = `${appUrl}/climbs/${uuid}`; anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; const climbNameHeader = document.getElementById("header-climb-name"); - climbNameHeader.innerHTML = anchor.outerHTML; + climbNameHeader.innerHTML = ""; + climbNameHeader.appendChild(anchor); + + const climbSetterHeader = document.getElementById("header-climb-setter"); + climbSetterHeader.textContent = `by ${setter}`; + + const climbStatsParagraph = document.getElementById("paragraph-climb-stats"); + climbStatsParagraph.textContent = difficultyAngleText; } -function drawResultsPage(pageNumber, pageSize) { +async function getResultsCount() { + const urlParams = new URLSearchParams(window.location.search); + const response = await fetch("/api/v1/search/count?" + urlParams); + const resultsCount = await response.json(); + return resultsCount; +} + +async function getResults(pageNumber, pageSize) { const urlParams = new URLSearchParams(window.location.search); urlParams.append("page", pageNumber); urlParams.append("pageSize", pageSize); - fetch("/api/v1/search?" + urlParams) - .then((response) => response.json()) - .then((results) => { - const resultsList = document.getElementById("div-results-list"); - for (const result of results) { - let listButton = document.createElement("button"); - listButton.setAttribute( - "class", - "list-group-item list-group-item-action" - ); + const response = await fetch("/api/v1/search?" + urlParams); + const results = await response.json(); + return results; +} - const [ - uuid, - setter, - name, - description, - frames, - angle, - ascents, - difficulty, - rating, - ] = result; +function drawResultsCount() { + getResultsCount().then((resultsCount) => { + const resultsCountHeader = document.getElementById("header-results-count"); + resultsCountHeader.textContent = `Found ${resultsCount} matching climbs`; + }); +} + +function drawResultsPage(pageNumber, pageSize) { + getResults(pageNumber, pageSize).then((results) => { + const resultsList = document.getElementById("div-results-list"); + for (const result of results) { + let listButton = document.createElement("button"); + listButton.setAttribute( + "class", + "list-group-item list-group-item-action" + ); - listButton.addEventListener("click", function () { - drawClimb(uuid, name, frames); - }); - const nameText = document.createElement("p"); - nameText.textContent = `${name} (${difficulty} at ${angle}\u00B0)`; - const statsText = document.createElement("p"); - statsText.textContent = `${ascents} ascents, ${rating.toFixed( - 2 - )}\u2605`; - statsText.classList.add("fw-light"); - listButton.appendChild(nameText); - listButton.appendChild(statsText); - resultsList.appendChild(listButton); + const [ + uuid, + setter, + name, + description, + frames, + angle, + ascents, + difficulty, + rating, + ] = result; + + const difficultyAngleText = `${difficulty} at ${angle}\u00B0`; + listButton.addEventListener("click", function () { + drawClimb(uuid, name, frames, setter, difficultyAngleText); + }); + const nameText = document.createElement("p"); + nameText.textContent = `${name} (${difficultyAngleText})`; + const statsText = document.createElement("p"); + statsText.textContent = `${ascents} ascents, ${rating.toFixed(2)}\u2605`; + statsText.classList.add("fw-light"); + listButton.appendChild(nameText); + listButton.appendChild(statsText); + resultsList.appendChild(listButton); + } + resultsList.onscroll = function (event) { + const { scrollHeight, scrollTop, clientHeight } = event.target; + if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) { + drawResultsPage(pageNumber + 1, pageSize); } - resultsList.onscroll = function (event) { - const { scrollHeight, scrollTop, clientHeight } = event.target; - if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) { - drawResultsPage(pageNumber + 1, pageSize); - } - }; - }); + }; + }); } +drawResultsCount(); drawResultsPage(0, 10); diff --git a/templates/boardSelection.html.j2 b/templates/boardSelection.html.j2 index cb7e1ed..5ecde90 100644 --- a/templates/boardSelection.html.j2 +++ b/templates/boardSelection.html.j2 @@ -19,15 +19,14 @@
Board
diff --git a/templates/results.html.j2 b/templates/results.html.j2 index 7649c9a..a9777d8 100644 --- a/templates/results.html.j2 +++ b/templates/results.html.j2 @@ -16,12 +16,13 @@ {% include 'heading.html.j2' %}
-
-

+

+
+

@@ -30,6 +31,7 @@