Files
doctate/server/templates/case_page.html
T
Brummel e795bdf6a5 feat: Display most recent recording time
Add `recorded_at_iso` and `time_hms_utc` to `CasePageTemplate` to
display the timestamp of the most recent recording.
Introduce a new partial `partials/time_format.js` for consistent date
and time formatting in the browser.
Update templates to use the new partial for rendering timestamps and
group headings.
2026-04-20 10:51:01 +02:00

199 lines
9.6 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; margin-bottom: 0.2em; }
h1 .title { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; flex: 1; min-width: 0; }
h1 .delete-form { margin: 0 0 0 auto; }
.case-time { color: #666; font-size: 1.05em; margin: 0 0 1em; }
.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:empty { display: none; }
.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; font-size: 1em; }
.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; }
.doc-actions { position: absolute; bottom: 0.5em; right: 0.5em; display: flex; gap: 0.2em; }
.doc-actions form { margin: 0; }
.copy-btn { 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">&larr; 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>
<span class="title">
{% 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 %}
</span>
<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>
</h1>
{% match recorded_at_iso %}
{% when Some with (iso) %}<p class="case-time"><time datetime="{{ iso }}">{{ time_hms_utc }}</time></p>
{% when None %}
{% endmatch %}
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
<div class="actions">
{% if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">Analysieren</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 %}
</div>
{% match document_html %}
{% when Some with (html) %}
<div id="doc-content" class="doc-content">
<div class="doc-actions">
{% if can_analyze %}<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit" class="copy-btn" title="Neu analysieren" aria-label="Neu analysieren"><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="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"></path><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"></path></svg></button></form>{% endif %}
<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>
<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">&rarr; Aufnahmen anzeigen ({{ recordings_count }})</a>
</div>
<script>
{% include "partials/time_format.js" %}
</script>
<script>
// Format the case timestamp as "Heute HH:MM" / "Gestern HH:MM" / "DD.MM.YYYY HH:MM"
// in the browser's local timezone and locale. Server-rendered text is only a
// no-JS fallback (UTC HH:MM).
(() => {
const el = document.querySelector('.case-time time[datetime]');
if (!el) return;
const d = new Date(el.dateTime);
if (isNaN(d.getTime())) return;
el.textContent = TimeFmt.dateLabel(d) + ' - ' + TimeFmt.timeLabel(d);
})();
(() => {
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');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
window.addEventListener('pagehide', () => es.close());
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>