diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2025-05-02 16:37:29 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2025-05-02 16:37:29 +0300 |
| commit | ef72b74ec34e95a38890d542cda298dd6565d5ad (patch) | |
| tree | 63fe96b24a8534e8dd2d88b1ca3d69b21c502d3e /demo | |
| parent | daca0e6b68d32d885face00d68df5b5cb00e098b (diff) | |
Start replacing browser with a chat demo
Diffstat (limited to 'demo')
| -rw-r--r-- | demo/app.py | 242 | ||||
| -rw-r--r-- | demo/requirements.txt | 11 | ||||
| -rw-r--r-- | demo/static/InclusiveSans-Regular.ttf | bin | 0 -> 57124 bytes | |||
| -rw-r--r-- | demo/static/htmp.js | 38 | ||||
| -rw-r--r-- | demo/static/styles.css | 140 | ||||
| -rw-r--r-- | demo/templates/base.html.j2 | 19 | ||||
| -rw-r--r-- | demo/templates/frag_aside.html.j2 | 27 | ||||
| -rw-r--r-- | demo/templates/frag_aside_link.html.j2 | 9 | ||||
| -rw-r--r-- | demo/templates/frag_error.html.j2 | 16 | ||||
| -rw-r--r-- | demo/templates/frag_form_delete.html.j2 | 7 | ||||
| -rw-r--r-- | demo/templates/frag_form_find.html.j2 | 7 | ||||
| -rw-r--r-- | demo/templates/frag_form_range.html.j2 | 21 | ||||
| -rw-r--r-- | demo/templates/frag_form_upsert.html.j2 | 5 | ||||
| -rw-r--r-- | demo/templates/frag_results.html.j2 | 18 | ||||
| -rw-r--r-- | demo/templates/page_error.html.j2 | 5 | ||||
| -rw-r--r-- | demo/templates/page_main.html.j2 | 11 |
16 files changed, 576 insertions, 0 deletions
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("/<op>") +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 Binary files differnew file mode 100644 index 0000000..3a30e79 --- /dev/null +++ b/demo/static/InclusiveSans-Regular.ttf 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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="preload" href="/static/InclusiveSans-Regular.ttf" as="font" type="font/woff2" crossorigin="anonymous"> + <link rel="stylesheet" href="/static/styles.css"></link> + {% block head_meta %} + <title>Database browser</title> + <meta name="description" content="Database browser for log_db"> + {% endblock %} +</head> +<body> + <h1><a href="/">Database browser</a></h1> + {% block content %}{% endblock %} + + <script src="/static/htmp.js"></script> +</body> +</html> 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 +#} + +<aside id="aside"> + <h3>Read operations</h3> + <ul> + {% with href="/find", text="Find" %} + <li>{% include "frag_aside_link.html.j2" %}</li> + {% endwith %} + {% with href="/range", text="Range" %} + <li>{% include "frag_aside_link.html.j2" %}</li> + {% endwith %} + </ul> + <h3>Write operations</h3> + <ul> + {% with href="/upsert", text="Upsert" %} + <li>{% include "frag_aside_link.html.j2" %}</li> + {% endwith %} + {% with href="/delete", text="Delete" %} + <li>{% include "frag_aside_link.html.j2" %}</li> + {% endwith %} + </ul> + + {% include selected_form %} +</aside> 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 +#} + +<form method="GET" action="{{ href }}" htmp replace=form_query> + <button class="link" type="submit">{{ text }}</button> +</form> 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 %} +<div id="{{ container }}"> +{% endif %} + +<h2>Error</h2> +{{ error }} + +{% if container %} +</div> +{% 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 @@ +<form id="form_query" action="/delete" method="post" htmp replace=results> + <label for="field">Field</label> + <input type="text" name="field" id="field" required> + <label for="value">Value</label> + <input type="text" name="value" id="value" required> + <button type="submit">Run query</button> +</form> 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 @@ +<form id="form_query" action="/find" method="post" htmp replace=results> + <label for="field">Field</label> + <input type="text" name="field" id="field" required> + <label for="values">Values (one per line)</label> + <textarea name="values" id="values" required></textarea> + <button type="submit">Run query</button> +</form> 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 @@ +<form id="form_query" action="/range" method="post" htmp replace=results> + <label for="field">Field</label> + <input type="text" name="field" id="field" required> + <label for="from_type">Lower bound type</label> + <select id="from_type" name="from_type" required> + <option value="unbounded">Unbounded</option> + <option value="included">Inclusive</option> + <option value="excluded">Exclusive</option> + </select> + <label for="values">Lower bound value</label> + <input type="text" name="from_value" id="from_value"> + <label for="to_type">Upper bound type</label> + <select id="to_type" name="to_type" required> + <option value="unbounded">Unbounded</option> + <option value="included">Inclusive</option> + <option value="excluded">Exclusive</option> + </select> + <label for="values">Upper bound value</label> + <input type="text" name="to_value" id="to_value"> + <button type="submit">Run query</button> +</form> 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 @@ +<form id="form_query" action="/upsert" method="post" htmp replace=results> + <label for="values">Values (one per line)</label> + <textarea name="values" id="values" required></textarea> + <button type="submit">Run query</button> +</form> 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 @@ +<table id="results"> +<thead> + <tr> + {% for field_name in field_names %} + <th>{{ field_name }}</th> + {% endfor %} + </tr> +</thead> +<tbody> + {% for row in rows %} + <tr> + {% for field in row %} + <td>{{ field }}</td> + {% endfor %} + </tr> + {% endfor %} +</tbody> +</table> 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 %} +<div id="container"> + {% include "frag_aside.html.j2" %} + + <main id="main"> + {% include "frag_results.html.j2" %} + </main> +</div> +{% endblock %} |
