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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user