Refactor editor and template components to enhance functionality and improve code structure
This commit is contained in:
parent
7c4958e0cb
commit
681f802e9c
8 changed files with 151 additions and 68 deletions
|
|
@ -1,44 +1,61 @@
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
function Editor(cfg) {
|
||||||
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++}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoResizeTextarea,
|
id: cfg.id,
|
||||||
createEditorWidget,
|
refreshUrl: cfg.refreshUrl,
|
||||||
createTempId
|
updateUrl: cfg.updateUrl,
|
||||||
|
createUrl: cfg.createUrl,
|
||||||
|
deleteUrl: cfg.deleteUrl,
|
||||||
|
updateUrl: cfg.updateUrl,
|
||||||
|
fieldName: cfg.fieldName,
|
||||||
|
recordId: cfg.recordId,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// initial render from whatever’s 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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})();
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
var score = {{ score }};
|
var score = {{ score }};
|
||||||
|
var initialScore = score;
|
||||||
const gridSize = {{ level + 3 }};
|
const gridSize = {{ level + 3 }};
|
||||||
var clickOrder = {};
|
var clickOrder = {};
|
||||||
var clickCounter = 0;
|
var clickCounter = 0;
|
||||||
|
|
@ -44,10 +45,9 @@
|
||||||
(clickOrder[key] ??= []).push(clickCounter);
|
(clickOrder[key] ??= []).push(clickCounter);
|
||||||
});
|
});
|
||||||
|
|
||||||
const bestScore = Object.values(clickOrder)
|
let bestScore = Object.values(clickOrder)
|
||||||
.reduce((n, arr) => n + (arr.length & 1), 0);
|
.reduce((n, arr) => n + (arr.length & 1), 0);
|
||||||
|
|
||||||
|
|
||||||
document.getElementById('best_score').textContent = `Perfect Clicks: ${bestScore}`;
|
document.getElementById('best_score').textContent = `Perfect Clicks: ${bestScore}`;
|
||||||
|
|
||||||
Object.entries(clickOrder).forEach(([key, value]) => {
|
Object.entries(clickOrder).forEach(([key, value]) => {
|
||||||
|
|
@ -107,7 +107,10 @@
|
||||||
const allUnchecked = 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 }} < 51) {
|
if (allChecked && !window.__alreadyNavigated && {{ level }} < 51) {
|
||||||
window.__alreadyNavigated = true;
|
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) {
|
} else if (allUnchecked && !window.__alreadyNavigated && {{ level }} > -2) {
|
||||||
window.__alreadyNavigated = true;
|
window.__alreadyNavigated = true;
|
||||||
location.href = `{{ url_for('main.coffee', level=level - 1) | safe }}&score=${score + bestScore}`;
|
location.href = `{{ url_for('main.coffee', level=level - 1) | safe }}&score=${score + bestScore}`;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
{% import "fragments/_icon_fragment.html" as icons %}
|
{% 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 -->
|
<!-- 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">
|
<div class="col">
|
||||||
<ul class="nav nav-tabs">
|
<ul class="nav nav-tabs">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|
@ -15,21 +24,20 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link{% if mode == 'edit' %} active{% endif %}" data-bs-toggle="tab"
|
<a class="nav-link{% if mode == 'edit' %} active{% endif %}" data-bs-toggle="tab"
|
||||||
data-bs-target="#editor{{ id }}" id="editTab{{ id }}"
|
data-bs-target="#editor{{ id }}" id="editTab{{ id }}" @shown.bs.tab="openEditTab()">
|
||||||
onclick="EditorWidget.autoResizeTextarea(textEditor{{ id }});">{{ icons.render_icon('pencil', 16)
|
{{ icons.render_icon('pencil', 16) }}
|
||||||
}}</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="tab-content" id="tabContent{{ id }}">
|
<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"
|
<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 %}"
|
<div class="tab-pane fade{% if mode == 'edit' %} show active border border-top-0{% endif %}"
|
||||||
id="editor{{ id }}">
|
id="editor{{ id }}" @change="renderViewer()">
|
||||||
<textarea id="textEditor{{ id }}" name="editor{{ id }}"
|
<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"
|
class="form-control border-top-0 rounded-top-0{% if not enabled %} disabled{% endif %} editor"
|
||||||
data-note-id="{{ id }}"
|
data-note-id="{{ id }}" @input="resizeEditor(); renderViewer()">{{ content if content }}</textarea>
|
||||||
oninput="EditorWidget.autoResizeTextarea(textEditor{{ id }}); viewer{{ id }}.innerHTML = marked.parse(textEditor{{ id }}.value);">{{ content if content }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -247,12 +247,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col p-3">
|
<div class="col p-3">
|
||||||
{{ editor.render_editor(
|
{{ editor.dynamic_editor(
|
||||||
id = "notes",
|
id = "notes",
|
||||||
title = "Notes & Comments",
|
title = "Notes & Comments",
|
||||||
mode = 'view' if item.id else 'edit',
|
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>
|
</div>
|
||||||
{% if worklog %}
|
{% if worklog %}
|
||||||
|
|
@ -278,7 +280,7 @@
|
||||||
{% set title %}
|
{% set title %}
|
||||||
{{ note.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ links.entry_link('worklog_entry', note.work_log_id) }}
|
{{ note.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ links.entry_link('worklog_entry', note.work_log_id) }}
|
||||||
{% endset %}
|
{% endset %}
|
||||||
{{ editor.render_editor(
|
{{ editor.dynamic_editor(
|
||||||
id = 'updates' + (note.id | string),
|
id = 'updates' + (note.id | string),
|
||||||
title = title,
|
title = title,
|
||||||
content = note.content,
|
content = note.content,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@
|
||||||
<main class="container-flex m-5">
|
<main class="container-flex m-5">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
|
integrity="sha384-j1CDi7MgGQ12Z7Qab0qlWQ/Qqz24Gc6BM0thvEMVjHnfYGF0rmFCozFSxQBxwHKO"
|
||||||
crossorigin="anonymous"></script>
|
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/marked/lib/marked.umd.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.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 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>
|
<script>
|
||||||
const searchInput = document.querySelector('#search');
|
const searchInput = document.querySelector('#search');
|
||||||
const searchButton = document.querySelector('#searchButton');
|
const searchButton = document.querySelector('#searchButton');
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = document.getElementById("editor-template");
|
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");
|
const updatesContainer = document.getElementById("updates-container");
|
||||||
updatesContainer.appendChild(newEditor);
|
updatesContainer.appendChild(newEditor);
|
||||||
|
|
@ -229,7 +229,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for update in log.updates %}
|
{% for update in log.updates %}
|
||||||
{{ editor.render_editor(
|
{{ editor.dynamic_editor(
|
||||||
id = update.id,
|
id = update.id,
|
||||||
title = update.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
title = update.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
mode = 'view',
|
mode = 'view',
|
||||||
|
|
@ -238,7 +238,7 @@
|
||||||
) }}
|
) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<template id="editor-template">
|
<template id="editor-template">
|
||||||
{{ editor.render_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
|
{{ editor.dynamic_editor('__ID__', '__TIMESTAMP__', 'edit', '') }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -159,3 +159,34 @@ def delete_item(model_name):
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({"error": "Constraint", "detail": str(e)}), 409
|
return jsonify({"error": "Constraint", "detail": str(e)}), 409
|
||||||
return jsonify({"deleted": deleted}), 200
|
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})
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,28 @@ def default_query(
|
||||||
|
|
||||||
return list(session.execute(stmt).scalars().all())
|
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):
|
def default_create(session, Model, payload):
|
||||||
label = infer_label_attr(Model)
|
label = infer_label_attr(Model)
|
||||||
obj = Model(**{label: payload.get(label) or payload.get("name")})
|
obj = Model(**{label: payload.get(label) or payload.get("name")})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue