From ef72b74ec34e95a38890d542cda298dd6565d5ad Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Fri, 2 May 2025 16:37:29 +0300 Subject: Start replacing browser with a chat demo --- demo/app.py | 242 ++++++++++++++++++++++++++++++++ demo/requirements.txt | 11 ++ demo/static/InclusiveSans-Regular.ttf | Bin 0 -> 57124 bytes demo/static/htmp.js | 38 +++++ demo/static/styles.css | 140 ++++++++++++++++++ demo/templates/base.html.j2 | 19 +++ demo/templates/frag_aside.html.j2 | 27 ++++ demo/templates/frag_aside_link.html.j2 | 9 ++ demo/templates/frag_error.html.j2 | 16 +++ demo/templates/frag_form_delete.html.j2 | 7 + demo/templates/frag_form_find.html.j2 | 7 + demo/templates/frag_form_range.html.j2 | 21 +++ demo/templates/frag_form_upsert.html.j2 | 5 + demo/templates/frag_results.html.j2 | 18 +++ demo/templates/page_error.html.j2 | 5 + demo/templates/page_main.html.j2 | 11 ++ 16 files changed, 576 insertions(+) create mode 100644 demo/app.py create mode 100644 demo/requirements.txt create mode 100644 demo/static/InclusiveSans-Regular.ttf create mode 100644 demo/static/htmp.js create mode 100644 demo/static/styles.css create mode 100644 demo/templates/base.html.j2 create mode 100644 demo/templates/frag_aside.html.j2 create mode 100644 demo/templates/frag_aside_link.html.j2 create mode 100644 demo/templates/frag_error.html.j2 create mode 100644 demo/templates/frag_form_delete.html.j2 create mode 100644 demo/templates/frag_form_find.html.j2 create mode 100644 demo/templates/frag_form_range.html.j2 create mode 100644 demo/templates/frag_form_upsert.html.j2 create mode 100644 demo/templates/frag_results.html.j2 create mode 100644 demo/templates/page_error.html.j2 create mode 100644 demo/templates/page_main.html.j2 (limited to 'demo') diff --git a/demo/app.py b/demo/app.py new file mode 100644 index 0000000..be79432 --- /dev/null +++ b/demo/app.py @@ -0,0 +1,242 @@ +from dotenv import load_dotenv +load_dotenv() + +import os +import sys +import traceback +import tempfile +from typing import cast +from flask import Flask, render_template, request +from flask_compress import Compress # type: ignore + +import log_db +from log_db import DB, Bound, Value + +app = Flask(__name__) +Compress(app) + +BASE_URL = os.environ["BASE_URL"] +DB_DIR = os.environ["DB_DIR"] if "DB_DIR" in os.environ else tempfile.TemporaryDirectory().name + +print("""\n""" + f"""BASE_URL: {BASE_URL}\n""" + f"""DB_DIR: {DB_DIR}\n""") + +db_fields = ["id", "name"] +db_types = ["int", "string"] + +db = DB \ + .configure() \ + .data_dir(DB_DIR) \ + .fields(db_fields) \ + .primary_key("id") \ + .secondary_keys(["name"]) \ + .initialize() + +def error(e: str, code: int): + error_text = f"HTTP {code}: {e}" + #return render_template("page_error.html.j2", error = error_text) + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_error.html.j2", error = error_text, container = htmp_target) + else: + return render_template("page_error.html.j2", error = error_text) + +@app.errorhandler(Exception) +def error_handler(e: Exception): + if hasattr(e, "code") and 400 >= getattr(e, "code") < 500: + return error(str(e), getattr(e, "code")) + else: + traceback.print_exception(e, file=sys.stderr) + return error("Internal Server Error", 500) + +@app.get("/") +def index_default(): + return index("find") + +@app.get("/") +def index(op: str): + if op not in ["find", "range", "upsert", "delete"]: + return error("Invalid operation", 400) + + rows = db.range_by("id", Bound.unbounded(), Bound.unbounded(), limit=100) + rows = [[value_to_str(v) for v in row] for row in rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template(f"frag_form_{op}.html.j2") + else: + return render_template('page_main.html.j2', + selected_form = f"frag_form_{op}.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/find") +def query_find(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + values = request.form.get("values") + if not values: raise ValueError("Values are required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + try: + values = [cast_to_value(field, v) for v in values.split("\n")] + values = [v for v in values if v is not None] + except ValueError as e: + return error(str(e), 400) + + tagged_rows = db.batch_find_by(field, values, limit=100) + rows = [[value_to_str(v) for v in row] for (_, row) in tagged_rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_find.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/range") +def query_range(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + from_type = request.form.get("from_type") + if not from_type: raise ValueError("From type is required") + + to_type = request.form.get("to_type") + if not to_type: raise ValueError("To type is required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + match from_type: + case "unbounded": + bound_lower = Bound.unbounded() + case "included": + from_value = request.form.get("from_value") + try: + if not from_value: raise ValueError("From value is required") + from_value = cast_to_value(field, from_value) + except ValueError as e: + return error(str(e), 400) + bound_lower = Bound.included(cast(Value, from_value)) + case "excluded": + from_value = request.form.get("from_value") + try: + if not from_value: raise ValueError("From value is required") + from_value = cast_to_value(field, from_value) + except ValueError as e: + return error(str(e), 400) + bound_lower = Bound.excluded(cast(Value, from_value)) + case _: + return error("Invalid from type", 400) + + match to_type: + case "unbounded": + bound_upper = Bound.unbounded() + case "included": + to_value = request.form.get("to_value") + try: + if not to_value: raise ValueError("To value is required") + to_value = cast_to_value(field, to_value) + except ValueError as e: + return error(str(e), 400) + bound_upper = Bound.included(cast(Value, to_value)) + case "excluded": + to_value = request.form.get("to_value") + try: + if not to_value: raise ValueError("To value is required") + to_value = cast_to_value(field, to_value) + except ValueError as e: + return error(str(e), 400) + bound_upper = Bound.excluded(cast(Value, to_value)) + case _: + return error("Invalid to type", 400) + + tagged_rows = db.range_by(field, bound_lower, bound_upper, limit=100) + rows = [[value_to_str(v) for v in row] for row in tagged_rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_range.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/delete") +def query_delete(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + value = request.form.get("value") + if not value: raise ValueError("Value is required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + try: + value = cast_to_value(field, value) + except ValueError as e: + return error(str(e), 400) + + rows = db.delete_by(field, cast(Value, value)) + rows = [[value_to_str(v) for v in row] for row in rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_delete.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + + +# Utils + +def cast_to_value(field: str, str_value: str) -> Value | None: + str_value = str_value.strip() + if str_value == "": return None + if str_value[0] == "\"": + if str_value[-1] != "\"": raise ValueError(f"Invalid string: {str_value}") + str_value = str_value[1:-1] + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + type = db_types[field_index] + + match type: + case "int": return Value.int(int(str_value)) + case "string": return Value.string(str_value) + case _: raise ValueError(f"Unsupported type: {type}") + +def value_to_str(value: Value) -> str: + match value.kind(): + case log_db.VALUE_INT: return f"{str(value.as_int())} (int)" + case log_db.VALUE_STRING: return f"\"{value.as_string()}\" (string)" + case log_db.VALUE_DECIMAL: return f"{value.as_decimal()} (decimal)" + case log_db.VALUE_BYTES: return f"{value.as_bytes()} (bytes)" + case log_db.VALUE_NULL: return "null" + case _: raise ValueError(f"Unsupported value kind: {value.kind()}") diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..b6b9bff --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.9.0 +Brotli==1.1.0 +click==8.1.8 +Flask==3.1.0 +Flask-Compress==1.17 +itsdangerous==2.2.0 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +python-dotenv==1.0.1 +Werkzeug==3.1.3 +zstandard==0.23.0 diff --git a/demo/static/InclusiveSans-Regular.ttf b/demo/static/InclusiveSans-Regular.ttf new file mode 100644 index 0000000..3a30e79 Binary files /dev/null and b/demo/static/InclusiveSans-Regular.ttf differ diff --git a/demo/static/htmp.js b/demo/static/htmp.js new file mode 100644 index 0000000..27d963b --- /dev/null +++ b/demo/static/htmp.js @@ -0,0 +1,38 @@ +const iframe = document.createElement("iframe"); +iframe.name = "htmp"; +iframe.hidden = true; +iframe.addEventListener("load", function () { + setTimeout(() => { + const cd = this.contentDocument; + const cw = this.contentWindow; + // If the server responds with an entire HTML document, replace the current document with it. + // To check whether the response is an entire page we check the presence of this very snippet. + if (cd.querySelector("iframe[name='htmp']")) { + document.documentElement.replaceWith(cd.documentElement); + // If the server responds with a fragment, replace the target element with it. + } else { + document + .querySelector(cw.location.hash || null) + ?.replaceWith(...cd.body.childNodes); + const url = new URL(cw.location.href); + url.searchParams.delete("htmp"); + url.hash = ""; + history.replaceState({}, "", url.toString()); + } + }); +}); +document.body.appendChild(iframe); + +document.querySelectorAll("form[htmp]").forEach(function (el) { + const r = el.attributes.replace.value; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "htmp"; + input.value = r; + el.appendChild(input); + + const action = new URL(el.action); + action.hash = r; + el.action = action.toString(); + el.target = "htmp"; +}); diff --git a/demo/static/styles.css b/demo/static/styles.css new file mode 100644 index 0000000..82bed74 --- /dev/null +++ b/demo/static/styles.css @@ -0,0 +1,140 @@ +:root { + --blue: rgb(30, 99, 248); + --red: #d42f2f; + --yellow: #ff9800; + --black: rgb(0, 0, 0); + --white: rgb(255, 255, 255); + --gray: #666; + --light-gray-1: #f2f2f2; + --light-gray-2: #f8f6ff; + --gold-trans: #ffcd0455; + + --font-family: "Inclusive Sans", sans-serif; +} + +@font-face { + font-family: "Inclusive Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/InclusiveSans-Regular.ttf) format("woff2"); +} + +html { + font-family: var(--font-family) !important; + font-size: 16px; +} + +body { + padding: 10px; + margin: 0 auto; + line-height: 24px; +} + +#container { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: flex-start; +} + +a, +.contrast { + color: var(--blue); +} + +h1 { + margin-bottom: 40px; +} + +h1 a { + color: var(--black); + text-decoration: none; +} + +h1:hover a { + border-bottom: 2px dotted var(--blue); + text-decoration: none; +} + +textarea, +input { + font-family: var(--font-family); + font-size: 16px; +} + +button, +input[type="submit"] { + cursor: pointer; + font-size: 16px; + border-radius: 4px; + text-shadow: none; + box-shadow: none; + padding: 5px 8px; + border: 0; + background-color: var(--blue); + color: var(--white); + + margin-top: 20px; +} + +button.link, +input[type="submit"].link { + background: none; + color: var(--blue); + padding: 0; + margin: 0; + text-decoration: underline; + font-family: "Inclusive Sans"; +} + +button.red, +input[type="submit"].red { + background-color: var(--red); +} + +button.blue, +input[type="submit"].blue { + background-color: var(--blue); +} + +button.yellow, +input[type="submit"].yellow { + background-color: var(--yellow); +} + +input[type="text"], +input[type*="date"] { + font-size: 16px; + color: var(--black); +} + +input[type="text"]:disabled, +input[type*="date"]:disabled { + color: var(--gray); +} + +aside { + width: 250px; +} + +main { + flex: 1; + overflow-y: scroll; + max-height: 100%; +} + +table { + width: 100%; +} + +th, +td { + min-width: 100px; + text-align: left; + padding: 7px 5px; +} + +tr:nth-of-type(odd) td { + background-color: var(--light-gray-2); +} diff --git a/demo/templates/base.html.j2 b/demo/templates/base.html.j2 new file mode 100644 index 0000000..47b3f6c --- /dev/null +++ b/demo/templates/base.html.j2 @@ -0,0 +1,19 @@ + + + + + + + + {% block head_meta %} + Database browser + + {% endblock %} + + +

Database browser

+ {% block content %}{% endblock %} + + + + diff --git a/demo/templates/frag_aside.html.j2 b/demo/templates/frag_aside.html.j2 new file mode 100644 index 0000000..6c5658f --- /dev/null +++ b/demo/templates/frag_aside.html.j2 @@ -0,0 +1,27 @@ +{# +Params + selected_form Form fragment to include +#} + + diff --git a/demo/templates/frag_aside_link.html.j2 b/demo/templates/frag_aside_link.html.j2 new file mode 100644 index 0000000..9cd6041 --- /dev/null +++ b/demo/templates/frag_aside_link.html.j2 @@ -0,0 +1,9 @@ +{# +Params + text Text to render in the link + href URL to link to +#} + +
+ +
diff --git a/demo/templates/frag_error.html.j2 b/demo/templates/frag_error.html.j2 new file mode 100644 index 0000000..c5034cd --- /dev/null +++ b/demo/templates/frag_error.html.j2 @@ -0,0 +1,16 @@ +{# +Params + error Error message to display + container (optional) id for a div to wrap the error message in +#} + +{% if container %} +
+{% endif %} + +

Error

+{{ error }} + +{% if container %} +
+{% endif %} diff --git a/demo/templates/frag_form_delete.html.j2 b/demo/templates/frag_form_delete.html.j2 new file mode 100644 index 0000000..22306c1 --- /dev/null +++ b/demo/templates/frag_form_delete.html.j2 @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/demo/templates/frag_form_find.html.j2 b/demo/templates/frag_form_find.html.j2 new file mode 100644 index 0000000..cadd44b --- /dev/null +++ b/demo/templates/frag_form_find.html.j2 @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/demo/templates/frag_form_range.html.j2 b/demo/templates/frag_form_range.html.j2 new file mode 100644 index 0000000..84b8024 --- /dev/null +++ b/demo/templates/frag_form_range.html.j2 @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + +
diff --git a/demo/templates/frag_form_upsert.html.j2 b/demo/templates/frag_form_upsert.html.j2 new file mode 100644 index 0000000..68820f5 --- /dev/null +++ b/demo/templates/frag_form_upsert.html.j2 @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/demo/templates/frag_results.html.j2 b/demo/templates/frag_results.html.j2 new file mode 100644 index 0000000..bfb54e9 --- /dev/null +++ b/demo/templates/frag_results.html.j2 @@ -0,0 +1,18 @@ + + + + {% for field_name in field_names %} + + {% endfor %} + + + + {% for row in rows %} + + {% for field in row %} + + {% endfor %} + + {% endfor %} + +
{{ field_name }}
{{ field }}
diff --git a/demo/templates/page_error.html.j2 b/demo/templates/page_error.html.j2 new file mode 100644 index 0000000..3233283 --- /dev/null +++ b/demo/templates/page_error.html.j2 @@ -0,0 +1,5 @@ +{% extends "base.html.j2" %} + +{% block content %} +{% include "frag_error.html.j2" %} +{% endblock %} diff --git a/demo/templates/page_main.html.j2 b/demo/templates/page_main.html.j2 new file mode 100644 index 0000000..907275f --- /dev/null +++ b/demo/templates/page_main.html.j2 @@ -0,0 +1,11 @@ +{% extends "base.html.j2" %} + +{% block content %} +
+ {% include "frag_aside.html.j2" %} + +
+ {% include "frag_results.html.j2" %} +
+
+{% endblock %} -- cgit v1.3