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

` : ''}`, todos: (r.todos && r.todos.length) ? `` : '
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)}
+
+ `, }; - 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)}
-
- -
-
-
+
${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