Files
MeetingTranskrip/index.html
2026-03-09 13:31:30 +01:00

1331 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>