Files
doctate/server/templates/case_recordings.html
T
Brummel 5effa7c96e feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The
endpoint
handles the removal of the audio file and its associated sidecar files
(transcript,
duration). It also invalidates derived artifacts like oneliner.json,
document.md,
and analysis_input.json. Additionally, it includes checks for path
traversal,
correct file extensions, and case ownership to ensure security and data
integrity.
A new `RecordingDeleted` event is also added to the `CaseEventKind`
enum.
2026-04-20 15:48:34 +02:00

318 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Aufnahmen 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; }
.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; }
.breadcrumb a { color: #4a90e2; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
.rec-time { font-size: 0.95em; color: #666; }
.filename { font-family: monospace; font-size: 0.8em; color: #999; }
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
.player { display: inline-flex; align-items: center; gap: 0.5em; flex: 1; justify-content: flex-end; }
.player .play {
position: relative;
width: 2em; height: 2em;
border: 1px solid #bbb; border-radius: 50%;
background: #fff; cursor: pointer; padding: 0;
flex-shrink: 0;
}
.player .play:hover { background: #f0f0f0; }
/* Play triangle — pseudo-element via border-trick, font-independent. */
.player .play::before {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 0; height: 0;
border-style: solid;
border-width: 7px 0 7px 10px;
border-color: transparent transparent transparent #333;
transform: translate(-35%, -50%);
}
/* Pause — two bars via ::before (reused) + ::after. */
.player .play.is-pause::before {
width: 3px; height: 12px;
border: none;
background: #333;
transform: translate(-5px, -50%);
}
.player .play.is-pause::after {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 3px; height: 12px;
background: #333;
transform: translate(2px, -50%);
}
.player .seek { flex: 1; max-width: 6em; cursor: pointer; }
.player .time { font-family: monospace; font-size: 0.85em; color: #555; min-width: 5.5em; text-align: right; }
.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; }
.rec-delete-form { margin: 0; }
.rec-delete-form .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;
}
.rec-delete-form .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
</style>
<script>
try {
if (localStorage.getItem('admin-view') === 'off') {
document.documentElement.classList.add('admin-view-off');
}
} catch (_) {}
</script>
</head>
<body>
<header>
<div class="breadcrumb">
<a href="/web/cases">&larr; Fälle</a>
{% match oneliner %}
{% when Some with (t) %}
<a href="/web/cases/{{ case_id }}">&larr; {{ t }}</a>
{% when None %}
<a href="/web/cases/{{ case_id }}">&larr; Fall</a>
{% endmatch %}
</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>Aufnahmen</h1>
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
{% for rec in recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}>
<button type="button" class="play" aria-label="Abspielen"></button>
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
<span class="time">0:00 / 0:00</span>
</div>
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
<input type="hidden" name="filename" value="{{ rec.filename }}">
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme 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>
</form>
</div>
{% if is_admin %}<div class="filename admin-only">{{ rec.filename }}</div>{% endif %}
{% if rec.failed %}
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
{% if t.is_empty() %}
<div class="pending">(stille Aufnahme)</div>
{% else %}
<div class="transcript">{{ t }}</div>
{% endif %}
{% when None %}
{% if transcribe_busy %}
<div class="pending">Transkription läuft…</div>
{% else %}
<div class="pending">Transkription ausstehend.</div>
{% endif %}
{% endmatch %}
{% endif %}
</div>
{% endfor %}
<script>
{% include "partials/time_format.js" %}
</script>
<script>
(() => {
const cb = document.getElementById('admin-view-toggle');
if (cb) {
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);
});
}
// Render the timestamp in the browser's local timezone using the same
// "Heute/Gestern/DD.MM.YYYY - HH:MM" format as the case page. Server
// renders the raw ISO into `datetime=""`; JS replaces the text.
const formatRec = d => TimeFmt.dateLabel(d) + ' - ' + TimeFmt.timeLabel(d);
document.querySelectorAll('.rec-time[datetime]').forEach(el => {
const iso = el.getAttribute('datetime');
if (!iso) return;
const d = new Date(iso);
if (isNaN(d.getTime())) return;
el.textContent = formatRec(d);
});
// Native confirm() before destructive delete. If JS is off, the form
// submits unconfirmed — acceptable for an internal tool.
document.querySelectorAll('.rec-delete-form').forEach(form => {
form.addEventListener('submit', e => {
const iso = form.dataset.ts;
let label = 'diese Aufnahme';
if (iso) {
const d = new Date(iso);
if (!isNaN(d.getTime())) label = 'die Aufnahme vom ' + formatRec(d);
}
if (!confirm(`Soll ${label} wirklich gelöscht werden?`)) e.preventDefault();
});
});
const fmtDur = s => {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
document.querySelectorAll('.player').forEach(p => {
const src = p.dataset.src;
const btn = p.querySelector('.play');
const seek = p.querySelector('.seek');
const timeEl = p.querySelector('.time');
const initialDuration = parseFloat(p.dataset.duration);
let audio = null;
let seeking = false;
// Pending seek expressed as a fraction of the full length (0..1). Used
// whenever we can't immediately apply `audio.currentTime` (metadata not
// yet loaded, or duration unknown at drop time). Cleared in `seeked`,
// so render() doesn't overwrite the slider with a stale currentTime
// reading between "set" and "applied".
let pendingSeek = null;
let duration = isFinite(initialDuration) ? initialDuration : 0;
const render = () => {
const cur = audio ? audio.currentTime : 0;
const displayTime = pendingSeek !== null ? pendingSeek * duration : cur;
timeEl.textContent = `${fmtDur(displayTime)} / ${fmtDur(duration)}`;
if (!seeking && pendingSeek === null) {
seek.value = duration > 0 ? Math.round((cur / duration) * 1000) : 0;
}
};
render();
const tryApplyPendingSeek = () => {
if (!audio || pendingSeek === null) return;
if (audio.readyState >= 1 && isFinite(audio.duration) && audio.duration > 0) {
audio.currentTime = pendingSeek * audio.duration;
// Keep pendingSeek set until `seeked` fires — the browser needs a
// moment to commit the seek, and any timeupdate in between would
// otherwise read the old currentTime.
}
};
const ensureAudio = () => {
if (audio) return audio;
audio = new Audio(src);
audio.preload = 'metadata';
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration) && audio.duration > 0) duration = audio.duration;
tryApplyPendingSeek();
render();
});
audio.addEventListener('timeupdate', render);
audio.addEventListener('seeked', () => {
pendingSeek = null;
render();
});
audio.addEventListener('ended', () => {
btn.classList.remove('is-pause');
btn.setAttribute('aria-label', 'Abspielen');
audio.currentTime = 0;
render();
});
return audio;
};
btn.addEventListener('click', () => {
const a = ensureAudio();
if (a.paused) {
a.play();
btn.classList.add('is-pause');
btn.setAttribute('aria-label', 'Pause');
} else {
a.pause();
btn.classList.remove('is-pause');
btn.setAttribute('aria-label', 'Abspielen');
}
});
seek.addEventListener('input', () => {
seeking = true;
if (duration <= 0) return;
// Live time-label update while dragging (no audio change yet).
const preview = (parseFloat(seek.value) / 1000) * duration;
timeEl.textContent = `${fmtDur(preview)} / ${fmtDur(duration)}`;
});
seek.addEventListener('change', () => {
seeking = false;
const fraction = parseFloat(seek.value) / 1000;
// Ensure audio exists even if duration is still unknown — this kicks
// off metadata loading, so loadedmetadata will apply the pending seek.
ensureAudio();
pendingSeek = fraction;
tryApplyPendingSeek();
render();
});
});
})();
// Live-update bus with audio-playback protection: only events for THIS
// case trigger a reload, and if any audio is currently playing, show a
// dismissible "Updates verfügbar"-banner instead of interrupting playback.
// The `.play.is-pause` class is set by the player script above while audio
// plays, so we can probe it via DOM selector without extra wiring.
(() => {
const MY_CASE_ID = "{{ case_id }}";
let timer = null;
const anyAudioPlaying = () => !!document.querySelector('.player .play.is-pause');
const showBanner = () => {
if (document.getElementById('update-banner')) return;
const b = document.createElement('button');
b.id = 'update-banner';
b.type = 'button';
b.textContent = '\u21bb Aktualisierungen verf\u00fcgbar \u2014 neu laden';
b.style.cssText = 'position:fixed;bottom:1em;right:1em;z-index:9999;'
+ 'padding:0.6em 1em;background:#fff3cd;border:1px solid #d39e00;'
+ 'border-radius:4px;cursor:pointer;font-family:inherit;';
b.addEventListener('click', () => location.reload());
document.body.appendChild(b);
};
const scheduleReload = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (anyAudioPlaying()) showBanner();
else 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>