GUI: alle Module inkl. Transkript-Panel spaltenuebergreifend verschiebbar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
schakal00783
2026-07-17 01:44:36 +02:00
co-authored by Claude Fable 5
parent 40602779ce
commit d55442a41f
3 changed files with 66 additions and 44 deletions
+48 -37
View File
@@ -391,7 +391,7 @@ async function openRecording(id) {
// Draggable module blocks; the saved order wins, unknown/new modules append.
// NOT modules: the transcript (own sticky right column) and the speaker-lane
// visualizer (fixed full-width strip under the player, spanning both columns).
const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos' };
const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', transcript: 'Transkript' };
const vizHtml = spkOrder.length ? `
<div class="viz-top">
<div class="viz-wrap">
@@ -420,11 +420,36 @@ async function openRecording(id) {
${r.summary ? `<p class="summary">${escapeHtml(r.summary)}</p>` : '<div class="muted">Noch keine Zusammenfassung.</div>'}
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}`,
todos: (r.todos && r.todos.length) ? `<ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : '<div class="muted">Keine ToDos erkannt.</div>',
transcript: `
<div id="transcript-view">
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
</div>
<div id="table-view" class="hidden">${buildTableScene(spkOrder, scls, names, spkLabel)}</div>`,
};
const defOrder = ['analyse', 'todos'];
let order = [];
try { order = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
order = order.filter(m => mods[m] !== undefined).concat(defOrder.filter(m => !order.includes(m)));
// Two drop columns; every module (incl. the transcript/table panel) can be
// dragged within AND between them. Layout persists for all recordings.
const defLayout = { main: ['analyse', 'todos'], side: ['transcript'] };
let layout = null;
try { layout = JSON.parse(localStorage.getItem('aime-detail-layout') || 'null'); } catch {}
if (!layout || !Array.isArray(layout.main) || !Array.isArray(layout.side)) {
// migrate the old single-column order if present
let old = [];
try { old = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
layout = { main: old.filter(m => m !== 'transcript'), side: [] };
}
layout.main = layout.main.filter(m => mods[m] !== undefined);
layout.side = layout.side.filter(m => mods[m] !== undefined && !layout.main.includes(m));
for (const m of [...defLayout.main, ...defLayout.side])
if (!layout.main.includes(m) && !layout.side.includes(m))
(defLayout.side.includes(m) ? layout.side : layout.main).push(m);
const modHtml = (m) => `
<section class="module${m === 'transcript' ? ' transcript-panel' : ''}" data-mod="${m}" ${m === 'transcript' ? 'id="transcript-panel"' : ''}>
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3 ${m === 'transcript' ? 'id="sidetitle"' : ''}>${MOD_TITLES[m]}</h3></div>
<div class="mod-body">${mods[m]}</div>
</section>`;
detail.innerHTML = `
<div class="card">
@@ -441,36 +466,19 @@ async function openRecording(id) {
${spkOrder.length ? `<button class="btn small icon" id="viewswitch" title="Ansicht wechseln: Konferenztisch / Transkript"></button>` : ''}
</div>
<div class="detail-cols">
<div class="detail-main">
<div id="modules">
${order.filter(m => mods[m]).map(m => `
<section class="module" data-mod="${m}">
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3>${MOD_TITLES[m]}</h3></div>
<div class="mod-body">${mods[m]}</div>
</section>`).join('')}
</div>
</div>
<div class="detail-side">
<section class="module transcript-panel" id="transcript-panel">
<div class="mod-head"><h3 id="sidetitle">Transkript</h3></div>
<div class="mod-body">
<div id="transcript-view">
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
</div>
<div id="table-view" class="hidden">${buildTableScene(spkOrder, scls, names, spkLabel)}</div>
</div>
</section>
</div>
<div class="mod-col" data-col="main">${layout.main.map(modHtml).join('')}</div>
<div class="mod-col" data-col="side">${layout.side.map(modHtml).join('')}</div>
</div>
</div>`;
// Drag & drop: grab a module by its header, drop it above/below the others;
// the order is saved and applies to every recording.
const modWrap = detail.querySelector('#modules');
// Drag & drop across BOTH columns: grab any module by its header, drop it in
// either column at any position; the layout is saved for all recordings.
const cols = [...detail.querySelectorAll('.mod-col')];
// ":empty" never matches because of template whitespace -> class-driven.
const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module')));
markEmpty();
let dragged = null;
modWrap.querySelectorAll('.mod-head').forEach(h => {
detail.querySelectorAll('.mod-head').forEach(h => {
h.addEventListener('dragstart', (e) => {
dragged = h.closest('.module');
dragged.classList.add('dragging');
@@ -481,17 +489,20 @@ async function openRecording(id) {
if (!dragged) return;
dragged.classList.remove('dragging');
dragged = null;
const now = [...modWrap.querySelectorAll('.module')].map(m => m.dataset.mod);
localStorage.setItem('aime-detail-order', JSON.stringify(now));
const save = {};
for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod);
localStorage.setItem('aime-detail-layout', JSON.stringify(save));
markEmpty();
});
});
modWrap.addEventListener('dragover', (e) => {
cols.forEach(col => col.addEventListener('dragover', (e) => {
if (!dragged) return;
e.preventDefault();
const others = [...modWrap.querySelectorAll('.module:not(.dragging)')];
const others = [...col.querySelectorAll('.module:not(.dragging)')];
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
if (next) modWrap.insertBefore(dragged, next); else modWrap.appendChild(dragged);
});
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
markEmpty();
}));
const reBtn = detail.querySelector('#reanalyze');
if (reBtn) reBtn.onclick = async () => {
// Re-runs speaker separation every time: empty = automatic detection (now
+9 -7
View File
@@ -204,16 +204,18 @@ audio { width: 100%; margin-bottom: 10px; }
/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */
.status-line { margin: 4px 0 10px; }
.detail-cols { display: flex; gap: 12px; align-items: flex-start; }
.detail-main { flex: 1 1 54%; min-width: 0; }
.detail-side { flex: 1 1 46%; min-width: 0; position: sticky; top: 12px; }
.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; }
.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; cursor: default; }
.mod-col { min-width: 0; display: flex; flex-direction: column; gap: 10px; min-height: 60px; }
.mod-col[data-col="main"] { flex: 1 1 54%; }
.mod-col[data-col="side"] { flex: 1 1 46%; }
.mod-col.empty { flex: 0 0 26px; border: 1px dashed var(--line); border-radius: 12px; align-self: stretch; }
.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; position: sticky; top: 12px; }
.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; }
@media (max-width: 1100px) {
.detail-cols { flex-direction: column; }
.detail-side { position: static; width: 100%; }
.transcript-panel { max-height: 60vh; }
.mod-col { width: 100%; }
.mod-col.empty { display: none; }
.transcript-panel { max-height: 60vh; position: static; }
}
#modules { display: flex; flex-direction: column; gap: 10px; }
.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.module.dragging { opacity: .45; border-style: dashed; }
.mod-head {