Refactor editor and template components to enhance functionality and improve code structure

This commit is contained in:
Yaro Kasear 2025-08-19 16:08:44 -05:00
parent 7c4958e0cb
commit 681f802e9c
8 changed files with 151 additions and 68 deletions

View file

@ -1,44 +1,61 @@
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('.editor').forEach(el => {
EditorWidget.autoResizeTextarea(el);
});
document.querySelectorAll('.viewer').forEach(el => {
const id = el.id.replace('viewer', '');
const textEditor = document.getElementById(`textEditor${id}`);
if (textEditor) {
el.innerHTML = marked.parse(textEditor.value);
}
});
});
const EditorWidget = (() => {
let tempIdCounter = 1;
function autoResizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight + 2}px`;
}
function createEditorWidget(template, id, timestamp, content = '') {
let html = template.innerHTML
.replace(/__ID__/g, id)
.replace(/__TIMESTAMP__/g, timestamp)
.replace(/__CONTENT__/g, content);
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
return wrapper.firstElementChild;
}
function createTempId(prefix = "temp") {
return `${prefix}-${tempIdCounter++}`;
}
function Editor(cfg) {
return {
autoResizeTextarea,
createEditorWidget,
createTempId
id: cfg.id,
refreshUrl: cfg.refreshUrl,
updateUrl: cfg.updateUrl,
createUrl: cfg.createUrl,
deleteUrl: cfg.deleteUrl,
updateUrl: cfg.updateUrl,
fieldName: cfg.fieldName,
recordId: cfg.recordId,
init() {
// initial render from whatevers in the textarea
this.renderViewer();
// then pull server value and re-render
if (this.refreshUrl) this.refresh();
},
buildRefreshUrl() {
if (!this.refreshUrl) return null;
const u = new URL(this.refreshUrl, window.location.origin);
u.search = new URLSearchParams({ field: this.fieldName, id: this.recordId }).toString();
return u.toString();
},
async refresh() {
const url = this.buildRefreshUrl();
if (!url) return;
const res = await fetch(url, { headers: { 'HX-Request': 'true' } });
const text = await res.text();
if (this.$refs.editor) {
this.$refs.editor.value = text;
this.resizeEditor();
this.renderViewer();
}
},
triggerRefresh() {
// use this anywhere to force a re-pull
this.$refs.container?.dispatchEvent(new CustomEvent('editor:refresh', { bubbles: true }));
},
openEditTab() {
this.$nextTick(() => { this.resizeEditor(); this.renderViewer(); });
},
resizeEditor() {
const ta = this.$refs.editor;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight + 2}px`;
},
renderViewer() {
const ta = this.$refs.editor, viewer = this.$refs.viewer;
if (!viewer || !ta) return;
const raw = ta.value || '';
viewer.innerHTML = (window.marked ? marked.parse(raw) : raw);
}
};
})();
}

View file

@ -32,6 +32,7 @@
{% block script %}
var score = {{ score }};
var initialScore = score;
const gridSize = {{ level + 3 }};
var clickOrder = {};
var clickCounter = 0;
@ -44,10 +45,9 @@
(clickOrder[key] ??= []).push(clickCounter);
});
const bestScore = Object.values(clickOrder)
let bestScore = Object.values(clickOrder)
.reduce((n, arr) => n + (arr.length & 1), 0);
document.getElementById('best_score').textContent = `Perfect Clicks: ${bestScore}`;
Object.entries(clickOrder).forEach(([key, value]) => {
@ -107,7 +107,10 @@
const allUnchecked = Array.from(document.querySelectorAll('.form-check-input')).every(cb => !cb.checked);
if (allChecked && !window.__alreadyNavigated && {{ level }} < 51) {
window.__alreadyNavigated = true;
location.href = `{{ url_for('main.coffee', level=level + 1) | safe }}&score=${score - bestScore}`;
if ((score - bestScore) == initialScore) {
bestScore *= 2;
}
location.href = `{{ url_for('main.coffee', level=level + 1) | safe }}&score=${Math.max(score - bestScore, 0)}`;
} else if (allUnchecked && !window.__alreadyNavigated && {{ level }} > -2) {
window.__alreadyNavigated = true;
location.href = `{{ url_for('main.coffee', level=level - 1) | safe }}&score=${score + bestScore}`;

View file

@ -1,8 +1,17 @@
{% import "fragments/_icon_fragment.html" as icons %}
{% macro render_editor(id, title, mode='edit', content=None, enabled=True) %}
{% macro dynamic_editor(id, title, mode='edit', content=none, enabled=true, create_url=none, refresh_url=none,
update_url=none, delete_url=none, field_name=none, record_id=none) %}
<!-- Editor Fragment -->
<div class="row mb-3">
<div class="row mb-3" id="editor-container-{{ id }}" x-data='Editor({
id: "{{ id }}",
createUrl: {{ create_url|tojson if create_url else "null" }},
refreshUrl: {{ refresh_url|tojson if refresh_url else "null" }},
deleteUrl: {{ delete_url|tojson if delete_url else "null" }},
updateUrl: {{ update_url|tojson if update_url else "null" }},
fieldName: {{ field_name|tojson if field_name else "null" }},
recordId: {{ record_id|tojson if record_id else "null" }}
})' x-ref="container" hx-preserve @editor:refresh.window="refresh()">
<div class="col">
<ul class="nav nav-tabs">
<li class="nav-item">
@ -15,21 +24,20 @@
</li>
<li class="nav-item">
<a class="nav-link{% if mode == 'edit' %} active{% endif %}" data-bs-toggle="tab"
data-bs-target="#editor{{ id }}" id="editTab{{ id }}"
onclick="EditorWidget.autoResizeTextarea(textEditor{{ id }});">{{ icons.render_icon('pencil', 16)
}}</a>
data-bs-target="#editor{{ id }}" id="editTab{{ id }}" @shown.bs.tab="openEditTab()">
{{ icons.render_icon('pencil', 16) }}
</a>
</li>
{% endif %}
</ul>
<div class="tab-content" id="tabContent{{ id }}">
<div class="tab-pane fade{% if mode == 'view' %} show active border border-top-0{% endif %} p-2 markdown-body viewer"
id="viewer{{ id }}"></div>
id="viewer{{ id }}" x-ref="viewer"></div>
<div class="tab-pane fade{% if mode == 'edit' %} show active border border-top-0{% endif %}"
id="editor{{ id }}">
<textarea id="textEditor{{ id }}" name="editor{{ id }}"
id="editor{{ id }}" @change="renderViewer()">
<textarea x-ref="editor" id="textEditor{{ id }}" name="editor{{ id }}"
class="form-control border-top-0 rounded-top-0{% if not enabled %} disabled{% endif %} editor"
data-note-id="{{ id }}"
oninput="EditorWidget.autoResizeTextarea(textEditor{{ id }}); viewer{{ id }}.innerHTML = marked.parse(textEditor{{ id }}.value);">{{ content if content }}</textarea>
data-note-id="{{ id }}" @input="resizeEditor(); renderViewer()">{{ content if content }}</textarea>
</div>
</div>
</div>

View file

@ -247,12 +247,14 @@
</div>
<div class="row">
<div class="col p-3">
{{ editor.render_editor(
{{ editor.dynamic_editor(
id = "notes",
title = "Notes & Comments",
mode = 'view' if item.id else 'edit',
content = item.notes if item.notes else '',
enabled = item.condition not in ["Removed", "Disposed"]
enabled = item.condition not in ["Removed", "Disposed"],
refresh_url = url_for('ui.get_value', model_name='inventory'),
field_name='notes',
record_id=item.id
) }}
</div>
{% if worklog %}
@ -278,7 +280,7 @@
{% set title %}
{{ note.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ links.entry_link('worklog_entry', note.work_log_id) }}
{% endset %}
{{ editor.render_editor(
{{ editor.dynamic_editor(
id = 'updates' + (note.id | string),
title = title,
content = note.content,

View file

@ -65,6 +65,12 @@
<main class="container-flex m-5">
{% block content %}{% endblock %}
</main>
<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>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/image.js') }}"></script>
<script src="{{ url_for('static', filename='js/toast.js') }}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
crossorigin="anonymous"></script>
@ -79,12 +85,6 @@
<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>
<script src="{{ url_for('static', filename='js/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/image.js') }}"></script>
<script src="{{ url_for('static', filename='js/toast.js') }}" defer></script>
<script>
const searchInput = document.querySelector('#search');
const searchButton = document.querySelector('#searchButton');

View file

@ -216,7 +216,7 @@
}
const template = document.getElementById("editor-template");
const newEditor = EditorWidget.createEditorWidget(template, EditorWidget.createTempId("new"), formatDate(new Date()));
const newEditor = createEditorWidget(template, createTempId("new"), formatDate(new Date()));
const updatesContainer = document.getElementById("updates-container");
updatesContainer.appendChild(newEditor);
@ -229,7 +229,7 @@
</div>
</div>
{% for update in log.updates %}
{{ editor.render_editor(
{{ editor.dynamic_editor(
id = update.id,
title = update.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
mode = 'view',
@ -238,7 +238,7 @@
) }}
{% endfor %}
<template id="editor-template">
{{ editor.render_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
{{ editor.dynamic_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
</template>
</div>
</div>

View file

@ -159,3 +159,34 @@ def delete_item(model_name):
db.session.rollback()
return jsonify({"error": "Constraint", "detail": str(e)}), 409
return jsonify({"deleted": deleted}), 200
@bp.get("/<model_name>/value")
def get_value(model_name):
Model = get_model_class(model_name)
field = (request.args.get("field") or "").strip()
if not field:
return jsonify({"error": "field required"}), 422
id_raw = request.args.get("id")
try:
id_ = int(id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid id"}), 422
# per-model override hook: ui_value(session, id_: int, field: str) -> Any
try:
val = call(Model, "ui_value", db.session, id_=id_, field=field)
if val is None:
# None means “no override,” fall back to default
from .defaults import default_value # or hoist to top if you like
val = default_value(db.session, Model, id_=id_, field=field)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# If HTMX hit this, keep the response boring and small
if request.headers.get("HX-Request"):
# text/plain keeps htmx happy for innerHTML swaps
return (str(val) if val is not None else ""), 200, {"Content-Type": "text/plain; charset=utf-8"}
return jsonify({"id": id_, "field": field, "value": val})

View file

@ -85,6 +85,28 @@ def default_query(
return list(session.execute(stmt).scalars().all())
def default_value(session, Model, *, id_: int, field: str) -> Any:
if '.' not in field:
col = _mapped_column(Model, field)
if col is None:
raise ValueError(f"Field '{field}' is not a mapped column on {Model.__name__}")
pk = inspect(Model).primary_key[0]
return session.scalar(select(col).where(pk == id_))
rel_name, rel_field = field.split('.', 1)
rel_attr = getattr(Model, rel_name, None)
if rel_attr is None or not hasattr(rel_attr, 'property'):
raise ValueError(f"Field '{field}' is not a valid relationship on {Model.__name__}")
Rel = rel_attr.property.mapper.class_
rel_col = _mapped_column(Rel, rel_field)
if rel_col is None:
raise ValueError(f"Field '{field}' is not a mapped column on {Rel.__name__}")
pk = inspect(Model).primary_key[0]
stmt = select(rel_col).join(getattr(Model, rel_name)).where(pk == id_).limit(1)
return session.scalar(stmt)
def default_create(session, Model, payload):
label = infer_label_attr(Model)
obj = Model(**{label: payload.get(label) or payload.get("name")})