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.
170 lines
8.4 KiB
HTML
170 lines
8.4 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Meine Fälle</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; }
|
|
section { margin: 1.5em 0; }
|
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
|
.empty { color: #888; font-style: italic; }
|
|
|
|
.case-list { list-style: none; padding: 0; margin: 0; }
|
|
.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
|
|
.case-row.done { border-left: 4px solid #7ed321; }
|
|
.case-row.open { border-left: 4px solid #4a90e2; }
|
|
.case-row .check { align-self: start; padding-top: 0.25em; }
|
|
.case-row .body { min-width: 0; }
|
|
.case-row .body a { color: inherit; text-decoration: none; }
|
|
.case-row .body a:hover .line1 { text-decoration: underline; }
|
|
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; }
|
|
.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; }
|
|
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
|
|
.case-row .line1 { font-size: 1em; margin: 0; }
|
|
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
|
|
.case-row .line2 { margin: 0.2em 0 0; color: #555; font-size: 0.95em; display: flex; align-items: center; gap: 0.5em; }
|
|
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
|
|
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
|
.status-badge.open { background: #e24a4a; }
|
|
.status-badge.done { background: #7ed321; }
|
|
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
|
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
|
|
.case-row .actions .delete-btn { padding: 0.3em; 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; }
|
|
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
|
|
|
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
|
|
.label.analyzing { background: #eee; color: #555; }
|
|
.label.done-doc { background: #7ed321; color: white; }
|
|
|
|
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
|
|
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
|
|
.bulk-bar .undo { margin-left: auto; }
|
|
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
|
|
|
|
.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>
|
|
<h1>{{ slug }} — Meine Fälle</h1>
|
|
<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>
|
|
|
|
{% if undo_count > 0 %}
|
|
<form class="bulk-bar" method="post" action="/web/cases/undo-delete" style="background:#fff8e1;">
|
|
<span><strong>{{ undo_count }}</strong> zuletzt gelöschte(r) Fall(/Fälle).</span>
|
|
<button type="submit">Löschung rückgängig</button>
|
|
</form>
|
|
{% endif %}
|
|
|
|
{% if total == 0 %}
|
|
<section><p class="empty">Keine Fälle.</p></section>
|
|
{% else %}
|
|
<form method="post" action="/web/cases/bulk">
|
|
{% for g in groups %}
|
|
<section>
|
|
<h2 class="date-group" data-iso="{{ g.label }}">{{ g.label }} ({{ g.cases.len() }})</h2>
|
|
<ul class="case-list">
|
|
{% for case in g.cases %}
|
|
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
|
|
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
|
|
<div class="body">
|
|
<a href="/web/cases/{{ case.case_id }}">
|
|
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
|
|
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
|
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
|
</a>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
|
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" 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>
|
|
</div>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</section>
|
|
{% endfor %}
|
|
<div class="bulk-bar">
|
|
<span>Auswahl:</span>
|
|
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
|
|
<button type="submit" name="action" value="analyze">Analysieren</button>
|
|
{% if is_admin %}<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
|
|
<button type="submit" name="action" value="delete">Entfernen</button>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
<script>
|
|
// Render all UTC timestamps in the browser's local timezone, and relabel
|
|
// date-group headings to Heute/Gestern based on the browser's current
|
|
// UTC date. Server-side renders only UTC so timezone logic belongs here.
|
|
(function () {
|
|
document.querySelectorAll('time[datetime]').forEach(function (el) {
|
|
var d = new Date(el.dateTime);
|
|
if (!isNaN(d.getTime())) {
|
|
el.textContent = d.toLocaleTimeString(undefined, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
});
|
|
|
|
var todayISO = new Date().toISOString().slice(0, 10);
|
|
var yesterday = new Date();
|
|
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
var yesterdayISO = yesterday.toISOString().slice(0, 10);
|
|
|
|
document.querySelectorAll('h2.date-group').forEach(function (h) {
|
|
var iso = h.dataset.iso;
|
|
if (!iso) return;
|
|
var suffix = h.textContent.replace(iso, '').trim();
|
|
var label;
|
|
if (iso === todayISO) label = 'Heute';
|
|
else if (iso === yesterdayISO) label = 'Gestern';
|
|
else label = iso;
|
|
h.textContent = label + ' ' + suffix;
|
|
});
|
|
})();
|
|
|
|
(() => {
|
|
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: any case event for this user triggers a debounced
|
|
// page reload. Debounce collapses event bursts (e.g. bulk-delete) into
|
|
// one reload. EventSource auto-reconnects on network drops.
|
|
(() => {
|
|
let timer = null;
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => location.reload(), 300);
|
|
};
|
|
const es = new EventSource('/web/events');
|
|
es.addEventListener('case', () => scheduleReload());
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|