e795bdf6a5
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.
199 lines
9.6 KiB
HTML
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">← 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">→ 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>
|