diff --git a/app/web/src/main.js b/app/web/src/main.js
index 9fbec63..d37a80a 100644
--- a/app/web/src/main.js
+++ b/app/web/src/main.js
@@ -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 ? `
@@ -420,11 +420,36 @@ async function openRecording(id) {
${r.summary ? `
${escapeHtml(r.summary)}
` : '
Noch keine Zusammenfassung.
'}
${(r.participants && r.participants.length) ? `
Teilnehmer
${r.participants.map(p => `- 👤 ${escapeHtml(p)}
`).join('')}
` : ''}`,
todos: (r.todos && r.todos.length) ? `
${r.todos.map(t => `- ${escapeHtml(t)}
`).join('')}
` : '
Keine ToDos erkannt.
',
+ transcript: `
+
+ ${r.transcript_clean ? `
Dialog (KI · Sprecher aus Kontext)
${renderDialog(r.transcript_clean)}
` : ''}
+
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
+
${renderTurns(r.words, names, scls, spkOrder)}
+
+
${buildTableScene(spkOrder, scls, names, spkLabel)}
`,
};
- 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) => `
+
+ â ¿
${MOD_TITLES[m]}
+ ${mods[m]}
+ `;
detail.innerHTML = `
@@ -441,36 +466,19 @@ async function openRecording(id) {
${spkOrder.length ? `` : ''}
-
-
- ${order.filter(m => mods[m]).map(m => `
-
- â ¿
${MOD_TITLES[m]}
- ${mods[m]}
- `).join('')}
-
-
-
-
- Transkript
-
-
- ${r.transcript_clean ? `
Dialog (KI · Sprecher aus Kontext)
${renderDialog(r.transcript_clean)}
` : ''}
-
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
-
${renderTurns(r.words, names, scls, spkOrder)}
-
-
${buildTableScene(spkOrder, scls, names, spkLabel)}
-
-
-
+
${layout.main.map(modHtml).join('')}
+
${layout.side.map(modHtml).join('')}
`;
- // 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
diff --git a/app/web/src/style.css b/app/web/src/style.css
index a9b01e8..1a2a967 100644
--- a/app/web/src/style.css
+++ b/app/web/src/style.css
@@ -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 {
diff --git a/docs/08-changelog.md b/docs/08-changelog.md
index ab8d42b..9be7bf4 100644
--- a/docs/08-changelog.md
+++ b/docs/08-changelog.md
@@ -3,6 +3,15 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
+## 2026-07-17 — GUI: Transkript/Am-Tisch-Panel ebenfalls verschiebbar (zwei Drop-Spalten)
+
+Alle Bloecke — Zusammenfassung, ToDos UND das Transkript-/Tisch-Panel — sind jetzt per Drag & Drop
+frei anordenbar, auch SPALTENUEBERGREIFEND: zwei Drop-Zonen (links 54 % / rechts 46 %), Layout
+persistiert als localStorage['aime-detail-layout'] {main:[...], side:[...]} (altes
+aime-detail-order wird migriert). Eine leere Spalte kollabiert zu einem schmalen gestrichelten
+Streifen und bleibt Drop-Ziel (JS-Klasse .empty — :empty scheitert an Template-Whitespace).
+Bundle `index-B1y2-Z_b.js`.
+
## 2026-07-17 — GUI: Sprecher-Modul entfernt, Umbenennen direkt am Verlauf
Das separate "Sprecher"-Modul war redundant zu den Spur-Labels. Jetzt: Klick auf einen Namen links