1d702e2d85
Introduces a system for broadcasting real-time events to the web UI. This enables features like automatic page reloads when case data changes in the background, improving the user experience by keeping the UI synchronized with the backend. Key components: - `events` module: Contains the `CaseEventKind` enum, `CaseEvent` struct, and `EventSender` type for managing the broadcast channel. - SSE endpoint (`/web/events`): Streams events to connected browsers. - Client-side JavaScript: Listens for events and triggers debounced page reloads. - Integration points: Workers and route handlers now emit events when relevant state changes occur.
169 lines
7.9 KiB
HTML
169 lines
7.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Fall {{ case_id_short }}</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
|
header { display: flex; justify-content: space-between; align-items: center; }
|
|
header form { margin: 0; }
|
|
.back { color: #4a90e2; text-decoration: none; }
|
|
h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
|
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
|
|
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; }
|
|
.status-badge.open { background: #4a90e2; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; }
|
|
.actions form { margin: 0; display: inline; }
|
|
.actions button { font-size: 1em; padding: 0.5em 1em; }
|
|
.actions .analyzing { color: #888; font-style: italic; }
|
|
.actions .llm-missing { color: #a66; font-style: italic; }
|
|
.actions .delete-form { margin-left: auto; }
|
|
.actions .delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
|
.actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
|
.placeholder { color: #888; font-style: italic; margin: 1em 0; }
|
|
.status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; }
|
|
.recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; }
|
|
.recordings-link:hover { text-decoration: underline; }
|
|
.doc-content { position: relative; font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; }
|
|
.doc-content p { margin: 0.8em 0; }
|
|
.doc-content p:first-child { margin-top: 0; }
|
|
.doc-content p:last-child { margin-bottom: 0; }
|
|
.doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; }
|
|
.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; }
|
|
.copy-btn:hover { color: #4a90e2; opacity: 1; }
|
|
.copy-btn .icon-check { display: none; }
|
|
.copy-btn.copied { color: #2d8c2d; opacity: 1; }
|
|
.copy-btn.copied .icon-copy { display: none; }
|
|
.copy-btn.copied .icon-check { display: block; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
|
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
|
html.admin-view-off .admin-only { display: none !important; }
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem('admin-view') === 'off') {
|
|
document.documentElement.classList.add('admin-view-off');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
|
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
<h1>
|
|
{% match oneliner %}
|
|
{% when Some with (t) %}{{ t }}
|
|
{% when None %}Fall
|
|
{% endmatch %}
|
|
{% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}
|
|
</h1>
|
|
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
|
|
|
<div class="actions">
|
|
{% if analyzing %}
|
|
<span class="analyzing">wird analysiert …</span>
|
|
{% else if can_analyze %}
|
|
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">{% if has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button></form>
|
|
{% else if llm_missing && !has_document %}
|
|
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
|
{% endif %}
|
|
{% if is_admin %}
|
|
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
|
|
{% endif %}
|
|
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button></form>
|
|
</div>
|
|
|
|
{% match document_html %}
|
|
{% when Some with (html) %}
|
|
<div id="doc-content" class="doc-content">
|
|
<button id="copy-btn" type="button" class="copy-btn" title="In Zwischenablage kopieren" hidden>
|
|
<svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
<svg class="icon-check" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
|
</button>
|
|
<div id="doc-body">{{ html|safe }}</div>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const btn = document.getElementById('copy-btn');
|
|
if (!btn) return;
|
|
btn.hidden = false;
|
|
btn.addEventListener('click', async () => {
|
|
const text = document.getElementById('doc-body').innerText;
|
|
let ok = false;
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
ok = true;
|
|
}
|
|
} catch (_) { /* fall through */ }
|
|
if (!ok) {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.setAttribute('readonly', '');
|
|
ta.style.position = 'fixed';
|
|
ta.style.top = '-1000px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { ok = document.execCommand('copy'); } catch (_) {}
|
|
ta.remove();
|
|
}
|
|
if (ok) {
|
|
btn.classList.add('copied');
|
|
setTimeout(() => btn.classList.remove('copied'), 1500);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% when None %}
|
|
{% if analyzing %}
|
|
<div class="placeholder">Wird analysiert …</div>
|
|
{% else if recordings_count == 0 %}
|
|
<div class="placeholder">Noch keine Aufnahmen vorhanden.</div>
|
|
{% else %}
|
|
<div class="status-panel">
|
|
{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert.
|
|
</div>
|
|
{% endif %}
|
|
{% endmatch %}
|
|
|
|
<div>
|
|
<a class="recordings-link" href="/web/cases/{{ case_id }}/recordings">→ Aufnahmen anzeigen ({{ recordings_count }})</a>
|
|
</div>
|
|
<script>
|
|
(() => {
|
|
const cb = document.getElementById('admin-view-toggle');
|
|
if (!cb) return;
|
|
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
|
cb.addEventListener('change', () => {
|
|
const on = cb.checked;
|
|
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
|
document.documentElement.classList.toggle('admin-view-off', !on);
|
|
});
|
|
})();
|
|
|
|
// Live-update bus: only events for THIS case trigger a debounced reload.
|
|
(() => {
|
|
const MY_CASE_ID = "{{ case_id }}";
|
|
let timer = null;
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => location.reload(), 300);
|
|
};
|
|
const es = new EventSource('/web/events');
|
|
es.addEventListener('case', (msg) => {
|
|
let evt;
|
|
try { evt = JSON.parse(msg.data); } catch (_) { return; }
|
|
if (evt.case_id === MY_CASE_ID) scheduleReload();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|