download refactoring
This commit is contained in:
361
src/public/css/style.css
Normal file
361
src/public/css/style.css
Normal file
@@ -0,0 +1,361 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #242736;
|
||||
--border: #2e3247;
|
||||
--accent: #6c8eff;
|
||||
--accent-dim: rgba(108, 142, 255, 0.15);
|
||||
--danger: #ff5f57;
|
||||
--success: #30d158;
|
||||
--text: #e8eaf0;
|
||||
--text-dim: #8b90a7;
|
||||
--text-muted: #555a72;
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 20px 16px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.header h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.header h1 span { color: var(--accent); }
|
||||
|
||||
.level-selector { display: flex; gap: 6px; }
|
||||
|
||||
.level-btn {
|
||||
padding: 5px 11px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.level-btn.active {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Screens ── */
|
||||
.screen { display: none; flex-direction: column; gap: 16px; }
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* ── Topic grid ── */
|
||||
.topic-intro { text-align: center; padding: 8px 0 4px; }
|
||||
.topic-intro p { color: var(--text-dim); font-size: 14px; line-height: 1.5; }
|
||||
|
||||
.topic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topic-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topic-card:hover, .topic-card:active {
|
||||
background: var(--surface2);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.topic-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.topic-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
|
||||
.topic-card p { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* ── Practice screen ── */
|
||||
.progress-bar-wrap { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-text { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.topic-badge { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.topic-badge button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
padding: 2px 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.topic-badge button:hover { color: var(--text); }
|
||||
.topic-badge span { font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
.question-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.question-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.question-text { font-size: 17px; font-weight: 500; line-height: 1.5; }
|
||||
|
||||
.speak-btn {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.speak-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.speak-btn.speaking { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
/* ── Record area ── */
|
||||
.record-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--surface2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.record-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.record-btn:hover::before { border-color: var(--accent); inset: -6px; }
|
||||
|
||||
.record-btn.recording {
|
||||
background: rgba(255, 95, 87, 0.15);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.record-btn.recording::before { border-color: var(--danger); inset: -6px; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 95, 87, 0.4); }
|
||||
50% { box-shadow: 0 0 0 14px rgba(255, 95, 87, 0); }
|
||||
}
|
||||
|
||||
.record-hint { font-size: 13px; color: var(--text-muted); text-align: center; }
|
||||
|
||||
/* ── Transcript ── */
|
||||
.transcript-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
min-height: 80px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.transcript-box.empty::after {
|
||||
content: 'Hier erscheint dein gesprochener Text...';
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.transcript-box[contenteditable="true"] { outline: none; cursor: text; border-color: var(--accent); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.actions { display: flex; gap: 10px; }
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--surface2); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { filter: none; }
|
||||
|
||||
/* ── Feedback ── */
|
||||
.feedback-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-box.visible { display: flex; }
|
||||
|
||||
.feedback-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.feedback-content { font-size: 14px; line-height: 1.7; color: var(--text-dim); }
|
||||
.feedback-content strong { color: var(--text); }
|
||||
.feedback-content h3 { font-size: 13px; color: var(--text); margin: 10px 0 4px; }
|
||||
.feedback-content ul { padding-left: 18px; }
|
||||
.feedback-content li { margin-bottom: 4px; }
|
||||
.feedback-content code { background: var(--surface2); padding: 1px 6px; border-radius: 4px; font-size: 13px; }
|
||||
|
||||
.loading-dots { display: flex; gap: 5px; align-items: center; }
|
||||
|
||||
.loading-dots span {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── History ── */
|
||||
.history-section { display: none; flex-direction: column; gap: 10px; }
|
||||
.history-section.has-items { display: flex; }
|
||||
|
||||
.history-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; }
|
||||
|
||||
.history-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.history-item:hover { border-color: var(--accent); }
|
||||
.history-item .hi-q { color: var(--text-dim); margin-bottom: 4px; }
|
||||
.history-item .hi-a { color: var(--text); }
|
||||
|
||||
/* ── Done screen ── */
|
||||
.done-screen { text-align: center; padding: 40px 0; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||
.done-screen .big-icon { font-size: 56px; }
|
||||
.done-screen h2 { font-size: 22px; font-weight: 600; }
|
||||
.done-screen p { color: var(--text-dim); font-size: 15px; line-height: 1.5; }
|
||||
|
||||
.stats-row { display: flex; gap: 16px; justify-content: center; }
|
||||
|
||||
.stat-box { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 20px; text-align: center; }
|
||||
.stat-box .stat-val { font-size: 24px; font-weight: 600; color: var(--accent); }
|
||||
.stat-box .stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
File diff suppressed because it is too large
Load Diff
372
src/public/js/app.js
Normal file
372
src/public/js/app.js
Normal file
@@ -0,0 +1,372 @@
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const MAX_RECORD_SECONDS = 30;
|
||||
const MAX_RECORD_WORDS = 60;
|
||||
const EXT_MAP = { 'audio/webm': 'webm', 'audio/ogg': 'ogg', 'audio/mp4': 'm4a', 'audio/mpeg': 'mp3' };
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
const state = {
|
||||
level: 'B1',
|
||||
currentTopic: null,
|
||||
currentIndex: 0,
|
||||
questionQueue: [],
|
||||
transcript: '',
|
||||
isRecording: false,
|
||||
isChecking: false,
|
||||
sessionHistory: [],
|
||||
};
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
const screens = {
|
||||
topics: document.getElementById('screen-topics'),
|
||||
practice: document.getElementById('screen-practice'),
|
||||
done: document.getElementById('screen-done'),
|
||||
};
|
||||
|
||||
const topicGrid = document.getElementById('topic-grid');
|
||||
const topicLabel = document.getElementById('topic-label');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const questionText = document.getElementById('question-text');
|
||||
const transcriptBox = document.getElementById('transcript-box');
|
||||
const feedbackBox = document.getElementById('feedback-box');
|
||||
const feedbackContent= document.getElementById('feedback-content');
|
||||
const recordBtn = document.getElementById('record-btn');
|
||||
const recordHint = document.getElementById('record-hint');
|
||||
const speakBtn = document.getElementById('speak-btn');
|
||||
const checkBtn = document.getElementById('check-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const skipBtn = document.getElementById('skip-btn');
|
||||
const nextActions = document.getElementById('next-actions');
|
||||
const nextBtn = document.getElementById('next-btn');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const historySection = document.getElementById('history-section');
|
||||
const historyList = document.getElementById('history-list');
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function showScreen(name) {
|
||||
Object.values(screens).forEach(s => s.classList.remove('active'));
|
||||
screens[name].classList.add('active');
|
||||
}
|
||||
|
||||
// ── Level selector ────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.level-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.level-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.level = btn.dataset.level;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Topic grid ────────────────────────────────────────────────────────────────
|
||||
function buildTopicGrid() {
|
||||
TOPICS.forEach(topic => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'topic-card';
|
||||
card.innerHTML = `<div class="topic-icon">${topic.icon}</div><h3>${topic.name}</h3><p>${topic.desc}</p>`;
|
||||
card.addEventListener('click', () => startTopic(topic));
|
||||
topicGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function startTopic(topic) {
|
||||
state.currentTopic = topic;
|
||||
state.sessionHistory = [];
|
||||
const indices = topic.questions.map((_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
state.questionQueue = indices;
|
||||
renderHistoryList();
|
||||
showScreen('practice');
|
||||
loadQuestion();
|
||||
}
|
||||
|
||||
// ── Questions ─────────────────────────────────────────────────────────────────
|
||||
function loadQuestion() {
|
||||
const topic = state.currentTopic;
|
||||
state.currentIndex = state.questionQueue[0];
|
||||
const total = topic.questions.length;
|
||||
const done = total - state.questionQueue.length;
|
||||
|
||||
topicLabel.textContent = `${topic.icon} ${topic.name}`;
|
||||
progressFill.style.width = `${(done / total) * 100}%`;
|
||||
progressText.textContent = `${done + 1} / ${total}`;
|
||||
questionText.textContent = topic.questions[state.currentIndex];
|
||||
|
||||
resetPracticeUI();
|
||||
autoSpeak();
|
||||
}
|
||||
|
||||
function resetPracticeUI() {
|
||||
state.transcript = '';
|
||||
transcriptBox.contentEditable = 'false';
|
||||
updateTranscriptBox('');
|
||||
feedbackBox.classList.remove('visible');
|
||||
feedbackContent.innerHTML = '';
|
||||
nextActions.style.display = 'none';
|
||||
checkBtn.disabled = true;
|
||||
skipBtn.disabled = false;
|
||||
downloadBtn.disabled = true;
|
||||
audioBlob = null;
|
||||
stopRecording();
|
||||
}
|
||||
|
||||
// ── Audio playback ────────────────────────────────────────────────────────────
|
||||
let audioPlayer = null;
|
||||
|
||||
function stopAudio() {
|
||||
if (audioPlayer) { audioPlayer.pause(); audioPlayer = null; }
|
||||
speakBtn.classList.remove('speaking');
|
||||
}
|
||||
|
||||
function autoSpeak() {
|
||||
stopAudio();
|
||||
const { id } = state.currentTopic;
|
||||
audioPlayer = new Audio(`audio/${id}_${state.currentIndex}.mp3`);
|
||||
speakBtn.classList.add('speaking');
|
||||
audioPlayer.onended = stopAudio;
|
||||
audioPlayer.onerror = stopAudio;
|
||||
audioPlayer.play();
|
||||
}
|
||||
|
||||
speakBtn.addEventListener('click', () => {
|
||||
if (audioPlayer && !audioPlayer.paused) { stopAudio(); return; }
|
||||
autoSpeak();
|
||||
});
|
||||
|
||||
// ── Speech recognition ────────────────────────────────────────────────────────
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
let recognition = null;
|
||||
|
||||
function initRecognition() {
|
||||
if (!SpeechRecognition) return null;
|
||||
const r = new SpeechRecognition();
|
||||
r.lang = 'de-DE';
|
||||
r.continuous = true;
|
||||
r.interimResults = false;
|
||||
r.maxAlternatives = 1;
|
||||
|
||||
let finalText = '';
|
||||
|
||||
r.onstart = () => {
|
||||
finalText = state.transcript;
|
||||
recordBtn.classList.add('recording');
|
||||
recordHint.textContent = 'Tippen zum Stoppen';
|
||||
state.isRecording = true;
|
||||
};
|
||||
|
||||
r.onresult = (e) => {
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal)
|
||||
finalText += (finalText ? ' ' : '') + e.results[i][0].transcript;
|
||||
}
|
||||
if (finalText.trim().split(/\s+/).length >= MAX_RECORD_WORDS) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
updateTranscriptBox(finalText);
|
||||
state.transcript = finalText;
|
||||
checkBtn.disabled = !finalText.trim();
|
||||
};
|
||||
|
||||
r.onend = () => { if (state.isRecording) stopRecording(); };
|
||||
r.onerror = (e) => { if (e.error !== 'no-speech') stopRecording(); };
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── MediaRecorder ─────────────────────────────────────────────────────────────
|
||||
let recordingTimer = null;
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let audioBlob = null;
|
||||
|
||||
async function startRecording() {
|
||||
if (!SpeechRecognition) {
|
||||
alert('Spracherkennung wird in diesem Browser nicht unterstützt. Bitte nutze Chrome oder Edge.');
|
||||
return;
|
||||
}
|
||||
stopAudio();
|
||||
audioBlob = null;
|
||||
audioChunks = [];
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
|
||||
mediaRecorder.onstop = () => {
|
||||
audioBlob = new Blob(audioChunks, { type: mimeType });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
if (audioBlob.size > 0) downloadBtn.disabled = false;
|
||||
};
|
||||
mediaRecorder.start();
|
||||
} catch (e) {
|
||||
console.warn('MediaRecorder unavailable:', e);
|
||||
}
|
||||
|
||||
transcriptBox.contentEditable = 'false';
|
||||
recognition = initRecognition();
|
||||
state.isRecording = true;
|
||||
try { recognition.start(); } catch (_) {}
|
||||
|
||||
clearTimeout(recordingTimer);
|
||||
recordingTimer = setTimeout(stopRecording, MAX_RECORD_SECONDS * 1000);
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
clearTimeout(recordingTimer);
|
||||
state.isRecording = false;
|
||||
if (recognition) {
|
||||
try { recognition.stop(); } catch (_) {}
|
||||
recognition = null;
|
||||
}
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder = null;
|
||||
}
|
||||
recordBtn.classList.remove('recording');
|
||||
recordHint.textContent = 'Tippen zum Aufnehmen';
|
||||
transcriptBox.contentEditable = 'true';
|
||||
}
|
||||
|
||||
recordBtn.addEventListener('click', () => state.isRecording ? stopRecording() : startRecording());
|
||||
|
||||
// ── Transcript box ────────────────────────────────────────────────────────────
|
||||
function updateTranscriptBox(text) {
|
||||
transcriptBox.textContent = text;
|
||||
transcriptBox.classList.toggle('empty', !text);
|
||||
}
|
||||
|
||||
transcriptBox.addEventListener('input', () => {
|
||||
state.transcript = transcriptBox.textContent.trim();
|
||||
checkBtn.disabled = !state.transcript;
|
||||
});
|
||||
|
||||
// ── Download recording ────────────────────────────────────────────────────────
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
if (!audioBlob) return;
|
||||
const topic = (state.currentTopic?.name || 'antwort').replace(/[^\w]/g, '_');
|
||||
const now = new Date();
|
||||
const ext = EXT_MAP[audioBlob.type.split(';')[0]] || 'webm';
|
||||
const filename = `${topic}_q${state.currentIndex + 1}_${now.getDate()}-${now.getMonth() + 1}.${ext}`;
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// ── Clear ─────────────────────────────────────────────────────────────────────
|
||||
clearBtn.addEventListener('click', () => {
|
||||
stopRecording();
|
||||
state.transcript = '';
|
||||
transcriptBox.contentEditable = 'false';
|
||||
updateTranscriptBox('');
|
||||
checkBtn.disabled = true;
|
||||
feedbackBox.classList.remove('visible');
|
||||
nextActions.style.display = 'none';
|
||||
});
|
||||
|
||||
// ── Check answer ──────────────────────────────────────────────────────────────
|
||||
checkBtn.addEventListener('click', async () => {
|
||||
const answer = state.transcript.trim();
|
||||
if (!answer || state.isChecking) return;
|
||||
|
||||
stopRecording();
|
||||
state.isChecking = true;
|
||||
checkBtn.disabled = true;
|
||||
feedbackBox.classList.add('visible');
|
||||
feedbackContent.innerHTML = '<div class="loading-dots"><span></span><span></span><span></span></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('api/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
question: state.currentTopic.questions[state.currentIndex],
|
||||
answer,
|
||||
level: state.level,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler');
|
||||
|
||||
feedbackContent.innerHTML = simpleMarkdown(data.feedback || '');
|
||||
state.sessionHistory.push({ question: state.currentTopic.questions[state.currentIndex], answer, feedback: data.feedback });
|
||||
renderHistoryList();
|
||||
} catch (err) {
|
||||
feedbackContent.innerHTML = `<span style="color:var(--danger)">Fehler: ${err.message}</span>`;
|
||||
} finally {
|
||||
state.isChecking = false;
|
||||
nextActions.style.display = 'flex';
|
||||
skipBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Markdown renderer ─────────────────────────────────────────────────────────
|
||||
function simpleMarkdown(text) {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/^#{1,3} (.+)$/gm,'<h3>$1</h3>')
|
||||
.replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^[-•] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^(.+)$/, '<p>$1</p>');
|
||||
}
|
||||
|
||||
// ── Skip / Retry / Next / Done ────────────────────────────────────────────────
|
||||
skipBtn.addEventListener('click', () => {
|
||||
stopRecording(); stopAudio();
|
||||
state.questionQueue.shift();
|
||||
state.questionQueue.length === 0 ? showDone() : loadQuestion();
|
||||
});
|
||||
|
||||
document.getElementById('retry-btn').addEventListener('click', () => { resetPracticeUI(); autoSpeak(); });
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
state.questionQueue.shift();
|
||||
state.questionQueue.length === 0 ? showDone() : loadQuestion();
|
||||
});
|
||||
|
||||
function showDone() {
|
||||
document.getElementById('stat-answered').textContent = state.sessionHistory.length;
|
||||
document.getElementById('stat-level').textContent = state.level;
|
||||
progressFill.style.width = '100%';
|
||||
showScreen('done');
|
||||
}
|
||||
|
||||
backBtn.addEventListener('click', () => { stopRecording(); stopAudio(); showScreen('topics'); });
|
||||
document.getElementById('restart-btn').addEventListener('click', () => showScreen('topics'));
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────────────────
|
||||
function renderHistoryList() {
|
||||
historySection.classList.toggle('has-items', state.sessionHistory.length > 0);
|
||||
historyList.innerHTML = '';
|
||||
[...state.sessionHistory].reverse().forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'history-item';
|
||||
el.innerHTML = `<div class="hi-q">F: ${escapeHtml(item.question)}</div><div class="hi-a">A: ${escapeHtml(item.answer)}</div>`;
|
||||
el.addEventListener('click', () => {
|
||||
feedbackContent.innerHTML = simpleMarkdown(item.feedback);
|
||||
feedbackBox.classList.add('visible');
|
||||
feedbackBox.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
historyList.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
buildTopicGrid();
|
||||
92
src/public/js/data.js
Normal file
92
src/public/js/data.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const TOPICS = [
|
||||
{
|
||||
id: 'alltag', icon: '🏠', name: 'Alltag', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Wie sieht dein typischer Morgen aus?',
|
||||
'Was machst du normalerweise in deiner Freizeit?',
|
||||
'Wie oft kochst du zu Hause und was bereitest du gerne zu?',
|
||||
'Beschreibe deinen Tagesablauf an einem Werktag.',
|
||||
'Welche Aufgaben im Haushalt magst du und welche nicht?',
|
||||
'Wie verbringst du deinen Feierabend?',
|
||||
'Machst du regelmäßig Sport? Was und wie oft?',
|
||||
'Wie oft triffst du dich mit Freunden oder Familie?',
|
||||
'Was liest du gerne – Bücher, Zeitungen oder Online-Artikel?',
|
||||
'Wie entspannst du dich nach einem stressigen Tag?',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'arbeit', icon: '💼', name: 'Arbeit & Beruf', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Was machst du beruflich und was gefällt dir an deiner Arbeit?',
|
||||
'Wie sieht dein Arbeitsplatz aus – Büro, Homeoffice oder beides?',
|
||||
'Was sind die größten Herausforderungen in deinem Job?',
|
||||
'Wie lange arbeitest du täglich und wie ist die Work-Life-Balance?',
|
||||
'Hast du nette Kollegen? Wie ist das Arbeitsklima?',
|
||||
'Welche Fähigkeiten brauchst du für deinen Beruf?',
|
||||
'Was würdest du beruflich ändern, wenn du könntest?',
|
||||
'Wie wichtig ist Karriere für dich im Vergleich zu Freizeit?',
|
||||
'Hast du schon mal den Job gewechselt? Warum?',
|
||||
'Was sind deine beruflichen Ziele für die Zukunft?',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reisen', icon: '✈️', name: 'Reisen', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Wohin bist du zuletzt gereist und wie war es?',
|
||||
'Reist du lieber alleine oder mit anderen? Warum?',
|
||||
'Was ist dein Traumreiseziel und warum?',
|
||||
'Bevorzugst du Strand, Berge oder Städtereisen?',
|
||||
'Wie planst du normalerweise deine Reisen?',
|
||||
'Was nimmst du immer mit auf Reisen?',
|
||||
'Hast du schon mal einen Kulturschock erlebt?',
|
||||
'Welches Essen aus einem anderen Land hat dir besonders gefallen?',
|
||||
'Wie wichtig ist es für dich, die Landessprache zu kennen?',
|
||||
'Erzähl von einem unvergesslichen Reiseerlebnis.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schweiz', icon: '🇨🇭', name: 'Schweiz & Bern', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Was magst du am Leben in der Schweiz besonders?',
|
||||
'Wie unterscheidet sich das Schweizerdeutsch vom Hochdeutsch?',
|
||||
'Was sind typisch schweizerische Gewohnheiten oder Traditionen?',
|
||||
'Welche Sehenswürdigkeiten in Bern kennst du?',
|
||||
'Was ist das Besondere am schweizerischen Bildungssystem?',
|
||||
'Wie ist das öffentliche Verkehrsnetz in der Schweiz?',
|
||||
'Was denkst du über die Mehrsprachigkeit in der Schweiz?',
|
||||
'Welche Schweizer Gerichte kennst du und magst du?',
|
||||
'Wie erleben Ausländer das Einleben in der Schweiz?',
|
||||
'Was sind Vor- und Nachteile der direkten Demokratie?',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gesundheit', icon: '🏥', name: 'Gesundheit', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Wie wichtig ist dir deine Gesundheit im Alltag?',
|
||||
'Treibst du Sport für die Gesundheit oder zum Spaß?',
|
||||
'Wie ernährst du dich – bewusst oder eher spontan?',
|
||||
'Wie gehst du mit Stress um?',
|
||||
'Schläfst du gut? Was machst du für besseren Schlaf?',
|
||||
'Gehst du regelmäßig zum Arzt für Vorsorgeuntersuchungen?',
|
||||
'Was meinst du – wie viel Einfluss hat man auf die eigene Gesundheit?',
|
||||
'Wie beeinflusst das Wetter deine Stimmung und Gesundheit?',
|
||||
'Hast du Allergien oder besondere gesundheitliche Themen?',
|
||||
'Was ist für dich gesunde Ernährung?',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'meinungen', icon: '💬', name: 'Meinungen', desc: '10 Fragen',
|
||||
questions: [
|
||||
'Was denkst du über soziale Medien – Fluch oder Segen?',
|
||||
'Glaubst du, dass Homeoffice die Produktivität steigert?',
|
||||
'Was meinst du: Ist lebenslanges Lernen wichtig?',
|
||||
'Wie stehst du zu vegetarischer oder veganer Ernährung?',
|
||||
'Glaubst du, dass KI unsere Arbeitswelt stark verändern wird?',
|
||||
'Was hältst du von öffentlichem Nahverkehr statt Autofahren?',
|
||||
'Ist es wichtig, mehrere Sprachen zu sprechen? Warum?',
|
||||
'Was denkst du über die vier-Tage-Arbeitswoche?',
|
||||
'Sollten mehr Menschen ehrenamtlich arbeiten?',
|
||||
'Wie wichtig ist lokales Einkaufen für die Umwelt?',
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user