feat: Add web UI for manual oneliner overrides

Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
This commit is contained in:
2026-04-26 17:17:09 +02:00
parent d732fdd8ec
commit 03736b6993
9 changed files with 531 additions and 49 deletions
+44 -1
View File
@@ -42,6 +42,38 @@
h1 .delete-form {
margin: 0 0 0 auto;
}
.oneliner-edit {
cursor: pointer;
}
.oneliner-edit:hover .oneliner-text {
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.oneliner-edit.editing {
cursor: default;
}
.oneliner-input {
font: inherit;
padding: 0.1em 0.3em;
min-width: 18em;
border: 1px solid #4a90e2;
border-radius: 3px;
background: white;
}
.oneliner-edit.saving .oneliner-input {
opacity: 0.6;
}
.oneliner-edit.error .oneliner-input {
border-color: #e24a4a;
background: #fff0f0;
}
.manual-marker {
font-size: 0.7em;
opacity: 0.55;
margin-left: 0.1em;
cursor: help;
vertical-align: middle;
}
.case-time {
color: #666;
font-size: 1.05em;
@@ -260,7 +292,15 @@
</header>
<h1>
<span class="title">
{% call ol::render(oneliner) %} {% if has_failed_recording
{% if is_closed %}
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
{% else %}
<span class="oneliner-edit" data-case-id="{{ case_id }}" data-csrf="{{ csrf_token }}">
<span class="oneliner-text">{% call ol::render(oneliner) %}</span>
</span>
{% endif %}
{% if oneliner_is_manual %}<span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}
{% if has_failed_recording
%}<span class="status-badge fehler">Fehler</span>{% else if
has_document %}<span class="status-badge done">ausgewertet</span
>{% else %}<span class="status-badge open">offen</span>{% endif
@@ -558,5 +598,8 @@
});
})();
</script>
<script>
{% include "partials/oneliner_edit.js" %}
</script>
</body>
</html>
+2 -1
View File
@@ -32,6 +32,7 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a
.case-row .case-link:hover { text-decoration: underline; }
.case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; }
.case-row .recordings-link:hover { text-decoration: underline; }
.case-row .manual-marker { font-size: 0.9em; opacity: 0.55; margin-left: 0.15em; cursor: help; }
.case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; }
.case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; }
.case-row .analysis.open .arrow { transform: rotate(90deg); }
@@ -111,7 +112,7 @@ try {
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body">
<div class="line1">
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div>
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
@@ -0,0 +1,76 @@
// Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner
// .oneliner-text for an <input>; Enter POSTs to /web/cases/{id}/oneliner;
// Escape (or blur) cancels. The SSE-driven reload picks up the persisted
// state, so no DOM patching is needed on success.
(() => {
const handleClick = (host) => {
if (host.classList.contains('editing')) return;
const span = host.querySelector('.oneliner-text');
if (!span) return;
const original = span.textContent.trim();
host.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.maxLength = 500;
input.className = 'oneliner-input';
span.replaceWith(input);
input.focus();
input.select();
const restore = () => {
const restoreSpan = document.createElement('span');
restoreSpan.className = 'oneliner-text';
restoreSpan.textContent = original;
input.replaceWith(restoreSpan);
host.classList.remove('editing', 'saving', 'error');
};
const submit = async () => {
const text = input.value.trim();
if (text === '' || text === original) { restore(); return; }
host.classList.remove('error');
host.classList.add('saving');
const body = new URLSearchParams({
text,
csrf_token: host.dataset.csrf,
});
try {
const r = await fetch(
`/web/cases/${host.dataset.caseId}/oneliner`,
{ method: 'POST', body, credentials: 'same-origin' }
);
if (!r.ok) {
host.classList.remove('saving');
host.classList.add('error');
input.focus();
return;
}
// SSE will trigger reload; keep input visible until then.
} catch (_) {
host.classList.remove('saving');
host.classList.add('error');
input.focus();
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('blur', () => {
// Blur acts as cancel unless we're already saving (where blur
// happens implicitly when the SSE reload swaps the DOM) or in
// an error state (so the user can re-focus and retry).
if (host.classList.contains('saving') || host.classList.contains('error')) return;
restore();
});
};
document.addEventListener('click', (e) => {
const host = e.target.closest('.oneliner-edit');
if (!host) return;
if (e.target.closest('a, button, input')) return;
handleClick(host);
});
})();