1331 lines
52 KiB
HTML
1331 lines
52 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Transcribe</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ═══════════════════════════════════════════
|
||
TOKENS
|
||
═══════════════════════════════════════════ */
|
||
:root {
|
||
--bg: #0a0a0a;
|
||
--surface: #111114;
|
||
--surface2: #18181c;
|
||
--border: #242428;
|
||
--border2: #2e2e34;
|
||
--text: #f0f0f2;
|
||
--text-2: #8b8b96;
|
||
--text-3: #4a4a52;
|
||
--accent: #ffffff;
|
||
--green: #4ade80;
|
||
--red: #f87171;
|
||
--radius-sm: 6px;
|
||
--radius: 10px;
|
||
--radius-lg: 14px;
|
||
--mono: 'JetBrains Mono', monospace;
|
||
--sans: 'Inter', system-ui, sans-serif;
|
||
--shadow: 0 4px 24px rgba(0,0,0,.5);
|
||
--shadow-lg: 0 8px 40px rgba(0,0,0,.7);
|
||
--transition: .18s ease;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESET + BASE
|
||
═══════════════════════════════════════════ */
|
||
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
|
||
|
||
html { height: 100%; }
|
||
|
||
body {
|
||
font-family: var(--sans);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100%;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
LAYOUT SHELLS
|
||
═══════════════════════════════════════════ */
|
||
.page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 40px 20px 32px;
|
||
}
|
||
|
||
.page.review-page {
|
||
align-items: stretch;
|
||
padding: 0;
|
||
}
|
||
|
||
.shell {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
HEADER
|
||
═══════════════════════════════════════════ */
|
||
.app-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
margin-bottom: 48px;
|
||
width: 100%;
|
||
}
|
||
|
||
.app-logo {
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 3px;
|
||
text-transform: uppercase;
|
||
color: var(--text);
|
||
}
|
||
|
||
.new-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: transparent;
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-2);
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
padding: 7px 13px;
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
.new-btn:hover { border-color: var(--text-2); color: var(--text); }
|
||
.new-btn svg { width: 13px; height: 13px; fill: currentColor; }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
SECTION LABEL
|
||
═══════════════════════════════════════════ */
|
||
.section-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
color: var(--text-3);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
DROP ZONE
|
||
═══════════════════════════════════════════ */
|
||
.drop-zone {
|
||
border: 1.5px dashed var(--border2);
|
||
border-radius: var(--radius-lg);
|
||
height: 160px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: border-color var(--transition), background var(--transition);
|
||
}
|
||
.drop-zone:not(.disabled):hover,
|
||
.drop-zone.dragover {
|
||
border-color: var(--text-2);
|
||
background: var(--surface);
|
||
}
|
||
.drop-zone.disabled { opacity: .3; pointer-events: none; }
|
||
.drop-zone input[type="file"] {
|
||
position: absolute; inset: 0; opacity: 0; cursor: pointer;
|
||
}
|
||
.drop-zone-icon {
|
||
width: 36px; height: 36px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: var(--surface2);
|
||
border-radius: 50%;
|
||
}
|
||
.drop-zone-icon svg { width: 18px; height: 18px; fill: var(--text-2); }
|
||
.drop-zone-label { font-size: 13px; color: var(--text-2); text-align: center; line-height: 1.4; }
|
||
.drop-zone-hint { font-size: 11px; color: var(--text-3); }
|
||
|
||
/* selected state */
|
||
.drop-zone .selected-info {
|
||
display: flex; flex-direction: column;
|
||
align-items: center; gap: 6px;
|
||
}
|
||
.selected-info .check-circle {
|
||
width: 36px; height: 36px; fill: var(--green);
|
||
}
|
||
.selected-info .file-name {
|
||
font-size: 13px; font-weight: 500; color: var(--text);
|
||
max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.selected-info .file-size { font-size: 11px; color: var(--text-3); }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
OR DIVIDER
|
||
═══════════════════════════════════════════ */
|
||
.or-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
margin: 24px 0;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
color: var(--text-3);
|
||
transition: opacity var(--transition);
|
||
}
|
||
.or-divider::before, .or-divider::after {
|
||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RECORD SECTION
|
||
═══════════════════════════════════════════ */
|
||
.record-section {
|
||
display: flex; flex-direction: column;
|
||
align-items: center; gap: 14px;
|
||
transition: opacity var(--transition);
|
||
}
|
||
.record-section.disabled { opacity: .3; pointer-events: none; }
|
||
|
||
.record-btn {
|
||
width: 72px; height: 72px;
|
||
border-radius: 50%;
|
||
border: 1.5px solid var(--border2);
|
||
background: var(--surface);
|
||
cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||
}
|
||
.record-btn:hover {
|
||
border-color: var(--text-2);
|
||
background: var(--surface2);
|
||
}
|
||
.record-btn.recording {
|
||
border-color: var(--red);
|
||
animation: pulse 1.6s ease infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,.35); }
|
||
50% { box-shadow: 0 0 0 14px rgba(248,113,113,0); }
|
||
}
|
||
.record-btn svg { width: 26px; height: 26px; fill: var(--text); }
|
||
|
||
.record-timer { font-family: var(--mono); font-size: 20px; color: var(--text); letter-spacing: 2px; }
|
||
.record-status {
|
||
font-size: 11px; font-weight: 600;
|
||
letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-3);
|
||
}
|
||
|
||
.audio-preview { width: 100%; margin-top: 4px; }
|
||
.audio-preview audio { width: 100%; height: 36px; filter: invert(1) brightness(.8); border-radius: var(--radius-sm); }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
BUTTONS
|
||
═══════════════════════════════════════════ */
|
||
.btn {
|
||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||
border: none; border-radius: var(--radius-sm);
|
||
font-family: var(--mono); font-weight: 700;
|
||
font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase;
|
||
cursor: pointer; transition: var(--transition); white-space: nowrap;
|
||
}
|
||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||
|
||
.btn-primary {
|
||
width: 100%; height: 48px;
|
||
background: var(--text); color: #000;
|
||
margin-top: 20px;
|
||
}
|
||
.btn-primary:not(:disabled):hover { background: #d8d8d8; }
|
||
|
||
.btn-ghost {
|
||
height: 36px; padding: 0 16px;
|
||
background: transparent; color: var(--text-2);
|
||
border: 1px solid var(--border2);
|
||
}
|
||
.btn-ghost:not(:disabled):hover { border-color: var(--text-2); color: var(--text); }
|
||
|
||
.btn-danger {
|
||
height: 30px; padding: 0 12px; font-size: 11px;
|
||
background: transparent; color: var(--red);
|
||
border: 1px solid var(--red);
|
||
}
|
||
.btn-danger:not(:disabled):hover { background: var(--red); color: #000; }
|
||
|
||
.btn-send {
|
||
height: 46px; padding: 0 28px;
|
||
background: var(--text); color: #000;
|
||
font-size: 12px; flex-shrink: 0;
|
||
}
|
||
.btn-send:not(:disabled):hover { background: #d8d8d8; }
|
||
.btn-send.success { background: var(--green); color: #000; }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
PROGRESS STEPS
|
||
═══════════════════════════════════════════ */
|
||
.steps { margin-top: 28px; border-top: 1px solid var(--border); }
|
||
.step {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 13px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.step-icon {
|
||
width: 20px; height: 20px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.step-icon svg { width: 14px; height: 14px; fill: var(--border2); }
|
||
.step.active .step-icon svg { fill: var(--text-2); animation: spin .8s linear infinite; }
|
||
.step.completed .step-icon svg { fill: var(--green); }
|
||
.step.error .step-icon svg { fill: var(--red); }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.step-label { font-size: 13px; color: var(--text-3); }
|
||
.step.active .step-label { color: var(--text); }
|
||
.step.completed .step-label { color: var(--green); }
|
||
.step.error .step-label { color: var(--red); }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
REVIEW SCREEN
|
||
═══════════════════════════════════════════ */
|
||
#reviewScreen { display: none; }
|
||
#reviewScreen.visible { display: block; }
|
||
|
||
.review-wrap {
|
||
max-width: 980px;
|
||
margin: 0 auto;
|
||
padding: 36px 24px 32px;
|
||
}
|
||
|
||
.review-grid {
|
||
display: grid;
|
||
grid-template-columns: 260px 1fr;
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
/* ── Speaker panel ── */
|
||
.speaker-panel {
|
||
position: sticky;
|
||
top: 20px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 18px;
|
||
max-height: var(--panel-max-h, 78vh);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 10px; font-weight: 700;
|
||
letter-spacing: 2px; text-transform: uppercase;
|
||
color: var(--text-3);
|
||
margin-bottom: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.speaker-panel-scroll {
|
||
flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0;
|
||
scrollbar-width: thin; scrollbar-color: var(--border2) transparent;
|
||
}
|
||
.speaker-panel-scroll::-webkit-scrollbar { width: 3px; }
|
||
.speaker-panel-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.speaker-list { display: flex; flex-direction: column; gap: 7px; margin-bottom: 12px; }
|
||
|
||
.speaker-row { display: flex; align-items: center; gap: 7px; }
|
||
.speaker-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||
|
||
.speaker-name-input {
|
||
flex: 1; min-width: 0;
|
||
padding: 6px 9px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text);
|
||
font-family: var(--sans);
|
||
font-size: 13px;
|
||
outline: none;
|
||
transition: border-color var(--transition);
|
||
}
|
||
.speaker-name-input:focus { border-color: var(--border2); }
|
||
|
||
.speaker-del-btn {
|
||
background: none; border: none; cursor: pointer;
|
||
color: var(--text-3); padding: 3px; line-height: 0; flex-shrink: 0;
|
||
border-radius: 4px; transition: color var(--transition);
|
||
}
|
||
.speaker-del-btn:hover { color: var(--red); }
|
||
.speaker-del-btn svg { width: 13px; height: 13px; fill: currentColor; }
|
||
|
||
.add-speaker-row { display: flex; gap: 6px; }
|
||
.add-speaker-input {
|
||
flex: 1;
|
||
padding: 6px 9px;
|
||
background: var(--bg);
|
||
border: 1px dashed var(--border2);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text);
|
||
font-family: var(--sans); font-size: 12px;
|
||
outline: none; transition: border-color var(--transition);
|
||
}
|
||
.add-speaker-input:focus { border-color: var(--text-2); }
|
||
.add-speaker-input::placeholder { color: var(--text-3); }
|
||
.add-speaker-btn {
|
||
width: 30px; height: 30px; flex-shrink: 0;
|
||
background: var(--bg); border: 1px dashed var(--border2);
|
||
border-radius: var(--radius-sm); color: var(--text-2);
|
||
font-size: 18px; line-height: 1; cursor: pointer;
|
||
transition: var(--transition); display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.add-speaker-btn:hover { border-color: var(--text-2); color: var(--text); }
|
||
|
||
/* ── Transcript panel ── */
|
||
.transcript-panel {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 18px;
|
||
max-height: var(--panel-max-h, 78vh);
|
||
display: flex; flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.transcript-scroll {
|
||
flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0;
|
||
scrollbar-width: thin; scrollbar-color: var(--border2) transparent;
|
||
}
|
||
.transcript-scroll::-webkit-scrollbar { width: 3px; }
|
||
.transcript-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
.transcript-segments { display: flex; flex-direction: column; gap: 10px; }
|
||
.segment { display: flex; gap: 9px; align-items: flex-start; }
|
||
.segment-text { flex: 1; font-size: 14px; line-height: 1.65; color: #c0c0cc; padding-top: 2px; }
|
||
|
||
/* ── Speaker chip ── */
|
||
.speaker-chip-wrap { position: relative; flex-shrink: 0; }
|
||
.speaker-chip {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 8px 3px 7px;
|
||
border-radius: 20px;
|
||
font-family: var(--sans);
|
||
font-size: 11px; font-weight: 600;
|
||
cursor: pointer; user-select: none; white-space: nowrap;
|
||
border: none; outline: none;
|
||
transition: opacity var(--transition);
|
||
}
|
||
.speaker-chip:hover { opacity: .8; }
|
||
.speaker-chip-arrow { width: 6px; height: 6px; opacity: .45; flex-shrink: 0; }
|
||
|
||
.speaker-dropdown {
|
||
position: absolute; top: calc(100% + 4px); left: 0;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius);
|
||
overflow: hidden; z-index: 50; min-width: 150px;
|
||
box-shadow: var(--shadow-lg); display: none;
|
||
}
|
||
.speaker-dropdown.open { display: block; }
|
||
.speaker-dropdown-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 9px 13px; font-size: 13px; cursor: pointer;
|
||
transition: background var(--transition); white-space: nowrap;
|
||
}
|
||
.speaker-dropdown-item:hover { background: var(--surface); }
|
||
.speaker-dropdown-item.selected { background: rgba(255,255,255,.04); }
|
||
.speaker-dropdown-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||
|
||
/* ── Review footer bar ── */
|
||
.review-footer {
|
||
margin-top: 20px;
|
||
display: flex; gap: 10px; align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.title-input {
|
||
flex: 1; min-width: 140px;
|
||
padding: 11px 14px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text);
|
||
font-family: var(--sans); font-size: 13px; font-weight: 600;
|
||
outline: none; transition: border-color var(--transition);
|
||
}
|
||
.title-input:focus { border-color: var(--border2); }
|
||
.title-input::placeholder { color: var(--text-3); font-weight: 400; }
|
||
|
||
.email-chip-field {
|
||
flex: 1.4; min-width: 180px;
|
||
display: flex; flex-wrap: wrap; align-items: center;
|
||
gap: 5px; padding: 6px 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: text; min-height: 46px;
|
||
transition: border-color var(--transition);
|
||
}
|
||
.email-chip-field:focus-within { border-color: var(--border2); }
|
||
|
||
.email-chip {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 3px 7px 3px 9px;
|
||
background: var(--surface2); border: 1px solid var(--border2);
|
||
border-radius: 20px; font-size: 12px; color: var(--text-2); white-space: nowrap;
|
||
}
|
||
.email-chip.invalid { border-color: var(--red); color: var(--red); }
|
||
.email-chip-remove {
|
||
background: none; border: none; cursor: pointer;
|
||
color: var(--text-3); font-size: 14px; line-height: 1; padding: 0;
|
||
transition: color var(--transition);
|
||
}
|
||
.email-chip-remove:hover { color: var(--text); }
|
||
.email-chip-input {
|
||
flex: 1; min-width: 120px;
|
||
background: transparent; border: none; outline: none;
|
||
color: var(--text); font-family: var(--sans); font-size: 13px; padding: 2px 0;
|
||
}
|
||
.email-chip-input::placeholder { color: var(--text-3); }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
MODAL
|
||
═══════════════════════════════════════════ */
|
||
.modal-overlay {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,.75);
|
||
backdrop-filter: blur(4px);
|
||
display: flex; align-items: center; justify-content: center;
|
||
z-index: 200;
|
||
}
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border2);
|
||
border-radius: var(--radius-lg);
|
||
padding: 28px 28px 24px;
|
||
width: 360px; max-width: 94vw;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
.modal-title {
|
||
font-size: 14px; font-weight: 700;
|
||
letter-spacing: .5px; margin-bottom: 8px;
|
||
}
|
||
.modal-subtitle {
|
||
font-size: 13px; color: var(--text-2);
|
||
margin-bottom: 20px; line-height: 1.5;
|
||
}
|
||
.modal select {
|
||
width: 100%; padding: 10px 12px;
|
||
background: var(--bg); border: 1px solid var(--border2);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text); font-family: var(--sans); font-size: 13px;
|
||
outline: none; margin-bottom: 20px;
|
||
}
|
||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||
.modal-actions .btn { height: 36px; padding: 0 16px; }
|
||
|
||
/* ═══════════════════════════════════════════
|
||
RESPONSIVE
|
||
═══════════════════════════════════════════ */
|
||
@media (max-width: 700px) {
|
||
.page { padding: 28px 16px 24px; }
|
||
|
||
.review-wrap { padding: 24px 16px 20px; }
|
||
.review-grid { grid-template-columns: 1fr; }
|
||
|
||
.speaker-panel { position: static; max-height: 240px; }
|
||
.transcript-panel { max-height: 55vh; }
|
||
|
||
.review-footer { flex-direction: column; align-items: stretch; }
|
||
.title-input, .email-chip-field { min-width: 0; flex: none; width: 100%; }
|
||
.btn-send { width: 100%; }
|
||
}
|
||
|
||
@media (max-width: 400px) {
|
||
.drop-zone { height: 140px; }
|
||
.app-logo { font-size: 11px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ══════════════════════════════════════════
|
||
UPLOAD SCREEN
|
||
══════════════════════════════════════════ -->
|
||
<div class="page" id="uploadPage">
|
||
<div class="shell">
|
||
|
||
<header class="app-header">
|
||
<span class="app-logo">Transcribe</span>
|
||
<button class="new-btn hidden" id="newBtn">
|
||
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||
Neu
|
||
</button>
|
||
</header>
|
||
|
||
<div style="margin-bottom:24px">
|
||
<div class="section-label">Audiodatei</div>
|
||
<div class="drop-zone" id="dropZone">
|
||
<input type="file" id="fileInput" accept=".mp3,.webm,.wav,.ogg,.flac,audio/*">
|
||
<div class="drop-zone-icon" id="uploadIcon">
|
||
<svg viewBox="0 0 24 24"><path d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
|
||
</div>
|
||
<div class="drop-zone-label" id="dropZoneText">Datei hierher ziehen<br>oder klicken zum Auswählen</div>
|
||
<div class="drop-zone-hint">MP3 · WAV · WebM · OGG · FLAC</div>
|
||
<div class="selected-info hidden" id="selectedInfo">
|
||
<svg class="check-circle" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5l-4.5-4.5 1.41-1.41L10 13.67l7.09-7.09 1.41 1.42L10 16.5z"/></svg>
|
||
<span class="file-name" id="selectedFileName"></span>
|
||
<span class="file-size" id="selectedFileSize"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="or-divider" id="orDivider">oder</div>
|
||
|
||
<div class="record-section" id="recordSection">
|
||
<button class="record-btn" id="recordBtn">
|
||
<svg viewBox="0 0 24 24" id="micIcon"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg>
|
||
<svg viewBox="0 0 24 24" id="stopIcon" class="hidden"><path d="M6 6h12v12H6z"/></svg>
|
||
</button>
|
||
<div class="record-timer hidden" id="recordTimer">00:00</div>
|
||
<div class="record-status" id="recordStatus">Aufnahme starten</div>
|
||
<div class="audio-preview hidden" id="audioPreview">
|
||
<audio id="audioPlayer" controls></audio>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" id="transcribeBtn" disabled>Transkribieren</button>
|
||
|
||
<div class="steps" id="steps">
|
||
<div class="step" id="step1">
|
||
<div class="step-icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg></div>
|
||
<span class="step-label">Hochladen</span>
|
||
</div>
|
||
<div class="step" id="step2">
|
||
<div class="step-icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg></div>
|
||
<span class="step-label">Transkribieren</span>
|
||
</div>
|
||
<div class="step" id="step3">
|
||
<div class="step-icon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg></div>
|
||
<span class="step-label">KI-Verarbeitung</span>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════
|
||
REVIEW SCREEN
|
||
══════════════════════════════════════════ -->
|
||
<div id="reviewScreen">
|
||
<div class="review-wrap">
|
||
|
||
<header class="app-header">
|
||
<span class="app-logo">Transcribe</span>
|
||
<button class="new-btn" id="newBtnReview">
|
||
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||
Neu
|
||
</button>
|
||
</header>
|
||
|
||
<div class="review-grid">
|
||
|
||
<div class="speaker-panel">
|
||
<div class="panel-title">Sprecher</div>
|
||
<div class="speaker-panel-scroll">
|
||
<div class="speaker-list" id="speakerList"></div>
|
||
<div class="add-speaker-row">
|
||
<input type="text" class="add-speaker-input" id="newSpeakerInput" placeholder="Name hinzufügen…">
|
||
<button class="add-speaker-btn" id="addSpeakerBtn" title="Hinzufügen">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="transcript-panel">
|
||
<div class="panel-title">Transkript</div>
|
||
<div class="transcript-scroll">
|
||
<div class="transcript-segments" id="transcriptSegments"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="review-footer">
|
||
<input type="text" class="title-input" id="titleInput" placeholder="Meeting-Titel…">
|
||
<div class="email-chip-field" id="emailChipField">
|
||
<input class="email-chip-input" id="emailChipInput" placeholder="E-Mail, Enter oder ","">
|
||
</div>
|
||
<button class="btn btn-send" id="sendBtn">Senden</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete modal -->
|
||
<div class="modal-overlay hidden" id="deleteModal">
|
||
<div class="modal">
|
||
<div class="modal-title">Sprecher entfernen</div>
|
||
<div class="modal-subtitle" id="deleteModalSub"></div>
|
||
<select id="deleteReplSel"></select>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-ghost" id="deleteCancelBtn">Abbrechen</button>
|
||
<button class="btn btn-danger" id="deleteConfirmBtn">Entfernen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════
|
||
// CONSTANTS
|
||
// ═══════════════════════════════════════════
|
||
const COLORS = [
|
||
'#4A9EFF','#FF6B6B','#51CF66','#FFD43B',
|
||
'#CC5DE8','#FF922B','#20C997','#F06595',
|
||
'#74C0FC','#A9E34B'
|
||
];
|
||
|
||
const DB_NAME = 'transcribe_app';
|
||
const DB_VERSION = 1;
|
||
const STORE_STATE = 'state';
|
||
const STORE_BLOB = 'blobs';
|
||
|
||
// ═══════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════
|
||
let state = {
|
||
screen: 'upload', inputMode: null,
|
||
uploadedFileName: '', uploadedFileSize: 0,
|
||
segments: [], speakers: [],
|
||
title: '', summary: '', emails: [],
|
||
};
|
||
|
||
let audioBlob = null;
|
||
let mediaRecorder = null;
|
||
let recordingChunks = [];
|
||
let recordingStart = null;
|
||
let timerInterval = null;
|
||
let speakerCounter = 0;
|
||
let pendingDeleteId = null;
|
||
let openDropdown = null;
|
||
let speakersMap = new Map();
|
||
|
||
// ═══════════════════════════════════════════
|
||
// INDEXEDDB
|
||
// ═══════════════════════════════════════════
|
||
let db;
|
||
function openDB() {
|
||
return new Promise((res, rej) => {
|
||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||
req.onupgradeneeded = e => {
|
||
const d = e.target.result;
|
||
if (!d.objectStoreNames.contains(STORE_STATE)) d.createObjectStore(STORE_STATE);
|
||
if (!d.objectStoreNames.contains(STORE_BLOB)) d.createObjectStore(STORE_BLOB);
|
||
};
|
||
req.onsuccess = e => { db = e.target.result; res(); };
|
||
req.onerror = e => rej(e.target.error);
|
||
});
|
||
}
|
||
const idbPut = (store, key, val) => new Promise((res, rej) => {
|
||
const req = db.transaction(store,'readwrite').objectStore(store).put(val, key);
|
||
req.onsuccess = res; req.onerror = e => rej(e.target.error);
|
||
});
|
||
const idbGet = (store, key) => new Promise((res, rej) => {
|
||
const req = db.transaction(store,'readonly').objectStore(store).get(key);
|
||
req.onsuccess = e => res(e.target.result); req.onerror = e => rej(e.target.error);
|
||
});
|
||
const idbDel = (store, key) => new Promise((res, rej) => {
|
||
const req = db.transaction(store,'readwrite').objectStore(store).delete(key);
|
||
req.onsuccess = res; req.onerror = e => rej(e.target.error);
|
||
});
|
||
const saveState = () => idbPut(STORE_STATE, 'main', state);
|
||
const saveBlob = b => idbPut(STORE_BLOB, 'audio', b);
|
||
const clearAll = () => Promise.all([idbDel(STORE_STATE,'main'), idbDel(STORE_BLOB,'audio')]);
|
||
|
||
// ═══════════════════════════════════════════
|
||
// DOM REFS
|
||
// ═══════════════════════════════════════════
|
||
const uploadPage = document.getElementById('uploadPage');
|
||
const reviewScreen = document.getElementById('reviewScreen');
|
||
const newBtn = document.getElementById('newBtn');
|
||
const newBtnReview = document.getElementById('newBtnReview');
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const uploadIcon = document.getElementById('uploadIcon');
|
||
const dropZoneText = document.getElementById('dropZoneText');
|
||
const selectedInfo = document.getElementById('selectedInfo');
|
||
const selectedFileName= document.getElementById('selectedFileName');
|
||
const selectedFileSize= document.getElementById('selectedFileSize');
|
||
const orDivider = document.getElementById('orDivider');
|
||
const recordSection = document.getElementById('recordSection');
|
||
const recordBtn = document.getElementById('recordBtn');
|
||
const micIcon = document.getElementById('micIcon');
|
||
const stopIcon = document.getElementById('stopIcon');
|
||
const recordTimer = document.getElementById('recordTimer');
|
||
const recordStatus = document.getElementById('recordStatus');
|
||
const audioPreview = document.getElementById('audioPreview');
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
const transcribeBtn = document.getElementById('transcribeBtn');
|
||
const speakerListEl = document.getElementById('speakerList');
|
||
const transcriptEl = document.getElementById('transcriptSegments');
|
||
const newSpeakerInput = document.getElementById('newSpeakerInput');
|
||
const addSpeakerBtn = document.getElementById('addSpeakerBtn');
|
||
const titleInput = document.getElementById('titleInput');
|
||
const emailChipField = document.getElementById('emailChipField');
|
||
const emailChipInput = document.getElementById('emailChipInput');
|
||
const sendBtn = document.getElementById('sendBtn');
|
||
const deleteModal = document.getElementById('deleteModal');
|
||
const deleteModalSub = document.getElementById('deleteModalSub');
|
||
const deleteReplSel = document.getElementById('deleteReplSel');
|
||
const deleteCancelBtn = document.getElementById('deleteCancelBtn');
|
||
const deleteConfirmBtn= document.getElementById('deleteConfirmBtn');
|
||
|
||
// ═══════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════
|
||
async function init() {
|
||
await openDB();
|
||
const saved = await idbGet(STORE_STATE, 'main');
|
||
if (saved) {
|
||
state = { ...state, ...saved };
|
||
speakersMap = new Map(state.speakers.map(s => [s.id, s]));
|
||
speakerCounter = state.speakers.length;
|
||
const blob = await idbGet(STORE_BLOB, 'audio');
|
||
if (blob) { audioBlob = blob; audioPlayer.src = URL.createObjectURL(blob); }
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// SCREEN CONTROLLER
|
||
// ═══════════════════════════════════════════
|
||
function renderAll() {
|
||
if (state.screen === 'review') { showReview(); }
|
||
else { showUpload(); }
|
||
}
|
||
|
||
function showUpload() {
|
||
uploadPage.style.display = '';
|
||
reviewScreen.classList.remove('visible');
|
||
newBtn.classList.toggle('hidden', !state.inputMode);
|
||
applyInputMode();
|
||
if (state.inputMode === 'file') showDropZoneSelected(state.uploadedFileName, state.uploadedFileSize);
|
||
if (state.inputMode === 'recording' && audioBlob) {
|
||
audioPreview.classList.remove('hidden');
|
||
recordStatus.textContent = 'Aufnahme bereit';
|
||
}
|
||
transcribeBtn.disabled = !state.inputMode;
|
||
}
|
||
|
||
function showReview() {
|
||
uploadPage.style.display = 'none';
|
||
reviewScreen.classList.add('visible');
|
||
speakersMap = new Map(state.speakers.map(s => [s.id, s]));
|
||
titleInput.value = state.title || '';
|
||
renderChips();
|
||
renderSpeakerPanel();
|
||
renderTranscript();
|
||
requestAnimationFrame(() => requestAnimationFrame(fitReviewLayout));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// INPUT MODE
|
||
// ═══════════════════════════════════════════
|
||
function setInputMode(mode) {
|
||
state.inputMode = mode;
|
||
applyInputMode();
|
||
newBtn.classList.remove('hidden');
|
||
saveState();
|
||
}
|
||
|
||
function applyInputMode() {
|
||
const fileMode = state.inputMode === 'file';
|
||
const recMode = state.inputMode === 'recording';
|
||
dropZone.classList.toggle('disabled', recMode);
|
||
recordSection.classList.toggle('disabled', fileMode);
|
||
orDivider.style.opacity = state.inputMode ? '.3' : '';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// DROP ZONE
|
||
// ═══════════════════════════════════════════
|
||
dropZone.addEventListener('dragover', e => {
|
||
if (state.inputMode === 'recording') return;
|
||
e.preventDefault(); dropZone.classList.add('dragover');
|
||
});
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||
dropZone.addEventListener('drop', e => {
|
||
e.preventDefault(); dropZone.classList.remove('dragover');
|
||
if (state.inputMode === 'recording') return;
|
||
if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]);
|
||
});
|
||
fileInput.addEventListener('change', e => {
|
||
if (e.target.files[0]) handleFileSelect(e.target.files[0]);
|
||
fileInput.value = '';
|
||
});
|
||
|
||
function handleFileSelect(file) {
|
||
audioBlob = file;
|
||
saveBlob(file);
|
||
setInputMode('file');
|
||
state.uploadedFileName = file.name;
|
||
state.uploadedFileSize = file.size;
|
||
saveState();
|
||
showDropZoneSelected(file.name, file.size);
|
||
transcribeBtn.disabled = false;
|
||
}
|
||
|
||
function showDropZoneSelected(name, size) {
|
||
uploadIcon.classList.add('hidden');
|
||
dropZoneText.classList.add('hidden');
|
||
selectedFileName.textContent = name;
|
||
selectedFileSize.textContent = formatBytes(size);
|
||
selectedInfo.classList.remove('hidden');
|
||
}
|
||
|
||
function formatBytes(b) {
|
||
if (b < 1024) return b + ' B';
|
||
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
|
||
return (b/1048576).toFixed(1) + ' MB';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// RECORDING
|
||
// ═══════════════════════════════════════════
|
||
recordBtn.addEventListener('click', async () => {
|
||
if (state.inputMode === 'file') return;
|
||
mediaRecorder?.state === 'recording' ? stopRecording() : await startRecording();
|
||
});
|
||
|
||
async function startRecording() {
|
||
try {
|
||
setInputMode('recording');
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
||
recordingChunks = [];
|
||
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recordingChunks.push(e.data); };
|
||
mediaRecorder.onstop = async () => {
|
||
audioBlob = new Blob(recordingChunks, { type: 'audio/webm' });
|
||
await saveBlob(audioBlob);
|
||
state.uploadedFileName = 'recording.webm';
|
||
state.uploadedFileSize = audioBlob.size;
|
||
await saveState();
|
||
audioPlayer.src = URL.createObjectURL(audioBlob);
|
||
audioPreview.classList.remove('hidden');
|
||
recordStatus.textContent = 'Aufnahme bereit';
|
||
transcribeBtn.disabled = false;
|
||
stream.getTracks().forEach(t => t.stop());
|
||
};
|
||
mediaRecorder.start();
|
||
recordingStart = Date.now();
|
||
recordBtn.classList.add('recording');
|
||
micIcon.classList.add('hidden');
|
||
stopIcon.classList.remove('hidden');
|
||
recordTimer.classList.remove('hidden');
|
||
recordStatus.textContent = 'Aufnahme stoppen';
|
||
timerInterval = setInterval(updateTimer, 1000);
|
||
} catch(err) {
|
||
console.error(err);
|
||
state.inputMode = null; applyInputMode();
|
||
}
|
||
}
|
||
|
||
function stopRecording() {
|
||
if (mediaRecorder?.state !== 'recording') return;
|
||
mediaRecorder.stop();
|
||
recordBtn.classList.remove('recording');
|
||
micIcon.classList.remove('hidden');
|
||
stopIcon.classList.add('hidden');
|
||
recordTimer.classList.add('hidden');
|
||
clearInterval(timerInterval);
|
||
}
|
||
|
||
function updateTimer() {
|
||
const s = Math.floor((Date.now() - recordingStart) / 1000);
|
||
recordTimer.textContent = `${String(Math.floor(s/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// NEW / RESET
|
||
// ═══════════════════════════════════════════
|
||
function resetApp() {
|
||
if (!confirm('Wirklich neu starten? Alle Daten gehen verloren.')) return;
|
||
stopRecording();
|
||
clearAll();
|
||
state = { screen:'upload', inputMode:null, uploadedFileName:'', uploadedFileSize:0,
|
||
segments:[], speakers:[], title:'', summary:'', emails:[] };
|
||
audioBlob = null; speakersMap = new Map(); speakerCounter = 0;
|
||
// Reset UI
|
||
[uploadIcon, dropZoneText].forEach(el => el.classList.remove('hidden'));
|
||
selectedInfo.classList.add('hidden');
|
||
audioPreview.classList.add('hidden');
|
||
audioPlayer.src = '';
|
||
recordStatus.textContent = 'Aufnahme starten';
|
||
transcribeBtn.disabled = true;
|
||
transcribeBtn.textContent = 'Transkribieren';
|
||
sendBtn.textContent = 'Senden';
|
||
sendBtn.style.background = ''; sendBtn.style.color = '';
|
||
sendBtn.disabled = false;
|
||
[1,2,3].forEach(n => setStep(n, null));
|
||
renderAll();
|
||
}
|
||
newBtn.addEventListener('click', resetApp);
|
||
newBtnReview.addEventListener('click', resetApp);
|
||
|
||
// ═══════════════════════════════════════════
|
||
// STEPS
|
||
// ═══════════════════════════════════════════
|
||
function setStep(n, s) {
|
||
const el = document.getElementById(`step${n}`);
|
||
el.classList.remove('active','completed','error');
|
||
if (s) el.classList.add(s);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// API
|
||
// ═══════════════════════════════════════════
|
||
async function api(url, init) {
|
||
const r = await fetch(url, init);
|
||
if (!r.ok) throw new Error(await r.text());
|
||
return r.json();
|
||
}
|
||
const apiUpload = (blob, name) => { const fd=new FormData(); fd.append('file',blob,name); return api('/upload',{method:'POST',body:fd}); };
|
||
const apiTranscribe = id => api('/transcribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:id})});
|
||
const apiProcess = text => { const fd=new FormData(); fd.append('text',text); return api('/process',{method:'POST',body:fd}); };
|
||
const apiSummarize = (transcript, title) => { const fd=new FormData(); fd.append('transcript',transcript); fd.append('title',title); return api('/summarize',{method:'POST',body:fd}); };
|
||
const apiSend = (email,title,sum,tr) => { const fd=new FormData(); fd.append('email',email); fd.append('title',title); fd.append('summary',sum); fd.append('transcript',tr); return api('/send',{method:'POST',body:fd}); };
|
||
|
||
// ═══════════════════════════════════════════
|
||
// TRANSCRIBE FLOW
|
||
// ═══════════════════════════════════════════
|
||
transcribeBtn.addEventListener('click', async () => {
|
||
transcribeBtn.disabled = true;
|
||
transcribeBtn.textContent = 'Verarbeitung…';
|
||
try {
|
||
setStep(1,'active');
|
||
const up = await apiUpload(audioBlob, state.uploadedFileName || 'audio.webm');
|
||
setStep(1,'completed');
|
||
|
||
setStep(2,'active');
|
||
const tr = await apiTranscribe(up.file_id);
|
||
setStep(2,'completed');
|
||
|
||
setStep(3,'active');
|
||
const pr = await apiProcess(tr.transcription);
|
||
setStep(3,'completed');
|
||
|
||
state.segments = parseTranscript(pr.processed);
|
||
buildSpeakersFromSegments();
|
||
state.screen = 'review';
|
||
await saveState();
|
||
showReview();
|
||
} catch(err) {
|
||
console.error(err);
|
||
setStep(1,'error');
|
||
transcribeBtn.disabled = false;
|
||
transcribeBtn.textContent = 'Transkribieren';
|
||
}
|
||
});
|
||
|
||
function parseTranscript(text) {
|
||
return text.split('\n').filter(l => l.trim()).map(line => {
|
||
const i = line.indexOf(':');
|
||
if (i === -1) return { speaker: 'SPRECHER', text: line.trim() };
|
||
return { speaker: line.slice(0,i).trim(), text: line.slice(i+1).trim() };
|
||
});
|
||
}
|
||
|
||
function buildSpeakersFromSegments() {
|
||
speakersMap = new Map(); speakerCounter = 0;
|
||
const seen = new Set();
|
||
state.segments.forEach(s => {
|
||
if (seen.has(s.speaker)) return; seen.add(s.speaker);
|
||
speakersMap.set(s.speaker, { id:s.speaker, name:s.speaker, color:COLORS[speakerCounter++%COLORS.length] });
|
||
});
|
||
syncSpeakers();
|
||
}
|
||
|
||
function syncSpeakers() { state.speakers = Array.from(speakersMap.values()); }
|
||
|
||
// ═══════════════════════════════════════════
|
||
// SPEAKER PANEL
|
||
// ═══════════════════════════════════════════
|
||
function renderSpeakerPanel() {
|
||
speakerListEl.innerHTML = '';
|
||
speakersMap.forEach(sp => {
|
||
const row = document.createElement('div'); row.className = 'speaker-row';
|
||
|
||
const dot = document.createElement('div');
|
||
dot.className = 'speaker-dot'; dot.style.background = sp.color;
|
||
|
||
const inp = document.createElement('input');
|
||
inp.type = 'text'; inp.className = 'speaker-name-input'; inp.value = sp.name;
|
||
inp.addEventListener('input', () => {
|
||
sp.name = inp.value || sp.id;
|
||
syncSpeakers(); saveState(); updateChipLabels();
|
||
});
|
||
|
||
const del = document.createElement('button');
|
||
del.className = 'speaker-del-btn'; del.title = 'Entfernen';
|
||
del.innerHTML = `<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`;
|
||
del.addEventListener('click', () => openDeleteModal(sp.id));
|
||
|
||
row.append(dot, inp, del);
|
||
speakerListEl.appendChild(row);
|
||
});
|
||
}
|
||
|
||
addSpeakerBtn.addEventListener('click', addSpeaker);
|
||
newSpeakerInput.addEventListener('keydown', e => { if (e.key==='Enter') addSpeaker(); });
|
||
|
||
function addSpeaker() {
|
||
const name = newSpeakerInput.value.trim(); if (!name) return;
|
||
const id = `SPEAKER_CUSTOM_${Date.now()}`;
|
||
speakersMap.set(id, { id, name, color: COLORS[speakerCounter++%COLORS.length] });
|
||
newSpeakerInput.value = '';
|
||
syncSpeakers(); saveState(); renderSpeakerPanel();
|
||
}
|
||
|
||
function openDeleteModal(id) {
|
||
if (speakersMap.size <= 1) { alert('Mindestens ein Sprecher muss bleiben.'); return; }
|
||
pendingDeleteId = id;
|
||
const sp = speakersMap.get(id);
|
||
deleteModalSub.textContent = `"${sp.name}" entfernen. Alle Segmente werden einem anderen Sprecher zugewiesen.`;
|
||
deleteReplSel.innerHTML = '';
|
||
speakersMap.forEach(s => {
|
||
if (s.id===id) return;
|
||
const o = document.createElement('option'); o.value=s.id; o.textContent=s.name;
|
||
deleteReplSel.appendChild(o);
|
||
});
|
||
deleteModal.classList.remove('hidden');
|
||
}
|
||
deleteCancelBtn.addEventListener('click', () => { deleteModal.classList.add('hidden'); pendingDeleteId=null; });
|
||
deleteConfirmBtn.addEventListener('click', async () => {
|
||
if (!pendingDeleteId) return;
|
||
const repl = deleteReplSel.value;
|
||
state.segments.forEach(s => { if (s.speaker===pendingDeleteId) s.speaker=repl; });
|
||
speakersMap.delete(pendingDeleteId); pendingDeleteId=null;
|
||
deleteModal.classList.add('hidden');
|
||
syncSpeakers(); await saveState();
|
||
renderSpeakerPanel(); renderTranscript();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════
|
||
// TRANSCRIPT + SPEAKER CHIPS
|
||
// ═══════════════════════════════════════════
|
||
document.addEventListener('click', e => {
|
||
if (openDropdown && !openDropdown.contains(e.target)) closeAllDropdowns();
|
||
});
|
||
function closeAllDropdowns() {
|
||
document.querySelectorAll('.speaker-dropdown.open').forEach(d => d.classList.remove('open'));
|
||
openDropdown = null;
|
||
}
|
||
|
||
function buildChip(idx) {
|
||
const seg = state.segments[idx];
|
||
const sp = speakersMap.get(seg.speaker) || { id:seg.speaker, name:seg.speaker, color:'#888' };
|
||
|
||
const wrap = document.createElement('div'); wrap.className = 'speaker-chip-wrap';
|
||
|
||
const chip = document.createElement('button'); chip.className = 'speaker-chip';
|
||
chip.style.background = sp.color + '22'; chip.style.color = sp.color;
|
||
chip.dataset.speakerId = sp.id;
|
||
|
||
const lbl = document.createElement('span'); lbl.textContent = sp.name;
|
||
const arrow = document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||
arrow.setAttribute('viewBox','0 0 10 6'); arrow.classList.add('speaker-chip-arrow');
|
||
arrow.innerHTML = '<path d="M0 0l5 6 5-6z" fill="currentColor"/>';
|
||
chip.append(lbl, arrow);
|
||
|
||
const dd = document.createElement('div'); dd.className = 'speaker-dropdown';
|
||
|
||
chip.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const wasOpen = dd.classList.contains('open');
|
||
closeAllDropdowns();
|
||
if (!wasOpen) {
|
||
dd.innerHTML = '';
|
||
speakersMap.forEach(s => {
|
||
const item = document.createElement('div');
|
||
item.className = 'speaker-dropdown-item' + (s.id===seg.speaker?' selected':'');
|
||
const dot = document.createElement('div');
|
||
dot.className='speaker-dropdown-dot'; dot.style.background=s.color;
|
||
const nm = document.createElement('span'); nm.textContent = s.name;
|
||
item.append(dot, nm);
|
||
item.addEventListener('click', async ev => {
|
||
ev.stopPropagation();
|
||
state.segments[idx].speaker = s.id;
|
||
await saveState();
|
||
closeAllDropdowns();
|
||
wrap.replaceWith(buildChip(idx));
|
||
});
|
||
dd.appendChild(item);
|
||
});
|
||
dd.classList.add('open'); openDropdown = wrap;
|
||
}
|
||
});
|
||
|
||
wrap.append(chip, dd);
|
||
return wrap;
|
||
}
|
||
|
||
function renderTranscript() {
|
||
transcriptEl.innerHTML = '';
|
||
state.segments.forEach((seg, i) => {
|
||
const div = document.createElement('div'); div.className = 'segment';
|
||
const txt = document.createElement('div'); txt.className = 'segment-text'; txt.textContent = seg.text;
|
||
div.append(buildChip(i), txt);
|
||
transcriptEl.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function updateChipLabels() {
|
||
document.querySelectorAll('.speaker-chip').forEach(chip => {
|
||
const id = chip.dataset.speakerId;
|
||
if (!speakersMap.has(id)) return;
|
||
const sp = speakersMap.get(id);
|
||
chip.querySelector('span').textContent = sp.name;
|
||
chip.style.background = sp.color + '22'; chip.style.color = sp.color;
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// EMAIL CHIPS
|
||
// ═══════════════════════════════════════════
|
||
const isValidEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||
|
||
function addEmail(raw) {
|
||
const parts = raw.split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);
|
||
let changed = false;
|
||
parts.forEach(addr => {
|
||
if (!(state.emails||[]).includes(addr)) { state.emails=[...(state.emails||[]),addr]; changed=true; }
|
||
});
|
||
if (changed) { saveState(); renderChips(); }
|
||
}
|
||
|
||
function removeEmail(addr) {
|
||
state.emails = (state.emails||[]).filter(e=>e!==addr);
|
||
saveState(); renderChips();
|
||
}
|
||
|
||
function renderChips() {
|
||
emailChipField.querySelectorAll('.email-chip').forEach(c=>c.remove());
|
||
(state.emails||[]).forEach(addr => {
|
||
const chip = document.createElement('div');
|
||
chip.className = 'email-chip' + (isValidEmail(addr)?'':' invalid');
|
||
const lbl = document.createElement('span'); lbl.textContent = addr;
|
||
const rm = document.createElement('button'); rm.className='email-chip-remove'; rm.textContent='×';
|
||
rm.addEventListener('click', () => removeEmail(addr));
|
||
chip.append(lbl, rm);
|
||
emailChipField.insertBefore(chip, emailChipInput);
|
||
});
|
||
}
|
||
|
||
emailChipInput.addEventListener('keydown', e => {
|
||
if (['Enter',',','Tab'].includes(e.key)) {
|
||
e.preventDefault();
|
||
const v = emailChipInput.value.trim().replace(/,+$/,'');
|
||
if (v) { addEmail(v); emailChipInput.value=''; }
|
||
} else if (e.key==='Backspace' && !emailChipInput.value) {
|
||
const em = state.emails||[];
|
||
if (em.length) removeEmail(em[em.length-1]);
|
||
}
|
||
});
|
||
emailChipInput.addEventListener('blur', () => {
|
||
const v = emailChipInput.value.trim().replace(/,+$/,'');
|
||
if (v) { addEmail(v); emailChipInput.value=''; }
|
||
});
|
||
emailChipInput.addEventListener('paste', e => {
|
||
e.preventDefault();
|
||
const parts = (e.clipboardData||window.clipboardData).getData('text').split(/[,;\s]+/).filter(Boolean);
|
||
if (parts.length) { addEmail(parts.join(',')); emailChipInput.value=''; }
|
||
});
|
||
emailChipField.addEventListener('click', () => emailChipInput.focus());
|
||
|
||
// ═══════════════════════════════════════════
|
||
// TITLE
|
||
// ═══════════════════════════════════════════
|
||
titleInput.addEventListener('input', async () => { state.title=titleInput.value; await saveState(); });
|
||
|
||
// ═══════════════════════════════════════════
|
||
// SEND
|
||
// ═══════════════════════════════════════════
|
||
function buildFinalTranscript() {
|
||
return state.segments.map(s => `${speakersMap.get(s.speaker)?.name??s.speaker}: ${s.text}`).join('\n');
|
||
}
|
||
|
||
sendBtn.addEventListener('click', async () => {
|
||
const pending = emailChipInput.value.trim().replace(/,+$/,'');
|
||
if (pending) { addEmail(pending); emailChipInput.value=''; }
|
||
|
||
const emails = (state.emails||[]).filter(isValidEmail);
|
||
if (!emails.length) {
|
||
emailChipField.style.borderColor = 'var(--red)';
|
||
emailChipInput.focus();
|
||
setTimeout(() => emailChipField.style.borderColor='', 1500);
|
||
return;
|
||
}
|
||
|
||
sendBtn.disabled = true;
|
||
const transcript = buildFinalTranscript();
|
||
const title = titleInput.value.trim() || 'Meeting';
|
||
|
||
try {
|
||
sendBtn.textContent = 'Zusammenfassung…';
|
||
const { summary } = await apiSummarize(transcript, title);
|
||
state.summary = summary; await saveState();
|
||
|
||
sendBtn.textContent = 'Senden…';
|
||
await Promise.all(emails.map(email => apiSend(email, title, summary, transcript)));
|
||
|
||
sendBtn.textContent = emails.length > 1 ? `Gesendet (${emails.length})` : 'Gesendet';
|
||
sendBtn.classList.add('success');
|
||
} catch(err) {
|
||
console.error(err);
|
||
sendBtn.disabled = false;
|
||
sendBtn.textContent = 'Fehler – erneut versuchen';
|
||
setTimeout(() => { sendBtn.textContent='Senden'; }, 3000);
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════
|
||
// REVIEW LAYOUT FIT
|
||
// ═══════════════════════════════════════════
|
||
function fitReviewLayout() {
|
||
if (state.screen !== 'review') return;
|
||
const header = reviewScreen.querySelector('.app-header');
|
||
const footer = reviewScreen.querySelector('.review-footer');
|
||
if (!header || !footer) return;
|
||
const headerH = header.getBoundingClientRect().height + parseInt(getComputedStyle(header).marginBottom||0);
|
||
const footerH = footer.getBoundingClientRect().height + parseInt(getComputedStyle(footer).marginTop||0);
|
||
const padTop = parseInt(getComputedStyle(document.body).paddingTop||0);
|
||
const wrapPad = 36 + 32; // review-wrap padding-top + padding-bottom
|
||
const maxH = Math.max(200, window.innerHeight - headerH - footerH - wrapPad - 20);
|
||
document.documentElement.style.setProperty('--panel-max-h', maxH + 'px');
|
||
}
|
||
window.addEventListener('resize', fitReviewLayout);
|
||
|
||
// ═══════════════════════════════════════════
|
||
// START
|
||
// ═══════════════════════════════════════════
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|