Starting work on new dynamic widgets using HTMX and Alpine.

This commit is contained in:
Yaro Kasear 2025-08-13 09:08:00 -05:00
parent 109176573c
commit c8190be21c
5 changed files with 174 additions and 3 deletions

View file

@ -36,6 +36,10 @@ def coffee():
matrix, clicked = generate_solvable_matrix(level)
return render_template("coffee.html", matrix=matrix, level=level, clicked=clicked)
@main.route("/playground")
def playground():
return render_template("playground.html")
@main.route("/")
def index():
worklog_query = eager_load_worklog_relationships(

View file

@ -7,11 +7,11 @@
{% endblock %}
{% block content %}
<div class="container border border-black rounded bg-success-subtle">
<div class="container border border-black bg-success-subtle">
{% for x in range(level + 3) %}
<div class="row" style="min-height: {{ 80 / (level + 3) }}vh;">
{% for y in range(level + 3) %}
<div class="col m-2 p-0 align-items-center d-flex justify-content-center border border-{% if (x, y) in clicked and false %}danger border-2{% else %}black{% endif %} rounded light shadow" id="light-{{ x }}-{{ y }}">
<div class="col m-0 p-0 align-items-center d-flex justify-content-center outline outline-{% if (x, y) in clicked and false %}danger border-2{% else %}black{% endif %} light" id="light-{{ x }}-{{ y }}">
<div class="form-check">
<input type="checkbox" class="form-check-input d-none" id="checkbox-{{ x }}-{{ y }}"{% if matrix[x][y] %} checked{% endif %}>
</div>
@ -85,7 +85,7 @@
// Check if all checkboxes are checked
const allChecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => cb.checked);
const allUnchecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => !cb.checked);
if (allChecked && !window.__alreadyNavigated && {{ level }} < 28) {
if (allChecked && !window.__alreadyNavigated && {{ level }} < 51) {
window.__alreadyNavigated = true;
location.href = "{{ url_for('main.coffee', level=level + 1) }}";
} else if (allUnchecked && !window.__alreadyNavigated && {{ level }} > -2) {

View file

@ -42,3 +42,150 @@
});
</script>
{% endmacro %}
{% macro dynamic_combobox(
id, options, label = none, placeholder = none, data_attributes = none,
create_url = none, edit_url = none, delete_url = none, refresh_url = none
) %}
{% if label %}
<label for="{{ id }}-input" class="form-label">{{ label }}</label>
{% endif %}
<div id="{{ id }}-container"
x-data="ComboBox({
id: '{{ id }}',
createUrl: {{ create_url|tojson if create_url else 'null' }},
editUrl: {{ edit_url|tojson if edit_url else 'null' }},
deleteUrl: {{ dekete_url|tojson if delete_url else 'null' }},
refreshUrl: {{ refresh_url|tojson if refresh_url else 'null' }},
})"
hx-preserve
class="combo-box-widget">
<div class="input-group">
<input type="text"
id="{{ id }}-input"
x-model.trim="query"
@keydown.enter.prevent="submitAddOrEdit()"
@keydown.escape="cancelEdit()"
class="form-control rounded-bottom-0">
<button id="{{ id }}-add"
:disabled="!query"
@click="submitAddOrEdit()"
class="btn btn-primary rounded-bottom-0">
<i class="bi icon-state" :class="isEditing ? 'bi-pencil' : 'bi-plus-lg'"></i>
</button>
<button id="{{ id }}-remove"
:disabled="!hasSelection"
@click="removeSelected()"
class="btn btn-danger rounded-bottom-0">
{{ icons.render_icon('dash-lg', 16) }}
</button>
</div>
<select id="{{ id }}-list" multiple
x-ref="list"
@change="onListChange"
class="form-select border-top-0 rounded-top-0"
name="{{ id }}" size="10">
{% for option in options %}
<option value="{{ option.id }}">{{ option.name }}</option>
{% endfor %}
</select>
{% if refresh_url %}
<div id="{{ id }}-htmx-refresh" class="d-none"
hx-get="{{ refresh_url }}"
hx-trigger="combobox:refresh from:#{{ id }}-container"
hx-target="#{{ id }}-list"
hx-swap="innerHTML"></div>
{% endif %}
</div>
<script>
function ComboBox(cfg) {
return {
id: cfg.id,
createUrl: cfg.createUrl,
editUrl: cfg.editUrl,
deleteUrl: cfg.deleteUrl,
refreshUrl: cfg.refreshUrl,
query: '',
isEditing: false,
editingOption: null,
get hasSelection() { return this.$refs.list?.selectedOptions.length > 0 },
onListChange() {
const sel = this.$refs.list.selectedOptions;
if (sel.length === 1) {
this.query = sel[0].textContent.trim();
this.isEditing = true;
this.editingOption = sel[0];
} else {
this.cancelEdit();
}
},
cancelEdit() { this.isEditing = false; this.editingOption = null; },
async submitAddOrEdit() {
const name = (this.query || '').trim();
if (!name) return;
if (this.isEditing && this.editingOption && this.editUrl) {
// EDIT
const id = this.editingOption.value;
await this._post(this.editUrl, { id, name });
this.editingOption.textContent = name;
} else if (this.createUrl) {
// CREATE
const data = await this._post(this.createUrl, { name });
const id = (data && data.id) ? data.id : ('temp-' + Math.random().toString(36).slice(2));
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
this.$refs.list.appendChild(opt);
this._sortOptions();
}
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
async removeSelected() {
const opts = Array.from(this.$refs.list.selectedOptions);
if (!opts.length) return;
if (!confirm(`Delete ${opts.length} item(s)?`)) return;
const ids = opts.map(o => o.remove());
this.query = '';
this.cancelEdit();
this._maybeRefresh();
},
_sortOptions() {
const list = this.$refs.list;
const sorted = Array.from(list.options).sort((a,b)=>a.text.localeCompare(b.text));
list.innerHTML = ''; sorted.forEach(o => list.appendChild(o));
},
_maybeRefresh() { if (this.refreshUrl) this.$dispatch('combobox:refresh'); },
async _post(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const msg = await res.text().catch(()=> 'Error');
}
}
}
}
</script>
{% endmacro %}

View file

@ -76,6 +76,8 @@
integrity="sha384-zqgMe4cx+N3TuuqXt4kWWDluM5g1CiRwqWBm3vpvY0GcDoXTwU8d17inavaLy3p3"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
<script src="{{ url_for('static', filename='js/csv.js') }}"></script>
<script src="{{ url_for('static', filename='js/dropdown.js') }}"></script>

View file

@ -0,0 +1,18 @@
{% extends 'layout.html' %}
{% block content %}
{{ combos.dynamic_combobox(
'play',
[
{
'id': 1,
'name': 'Beans'
},
{
'id': 2,
'name': 'Toast'
},
],
'Breakfast!'
) }}
{% endblock %}