initial commit

This commit is contained in:
2026-02-12 18:45:10 +01:00
commit be7bbba456
42 changed files with 3767 additions and 0 deletions

182
frontend/admin.html Normal file
View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Admin Dashboard">
<title>FluentGerman.ai — Admin</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<div class="logo">FluentGerman.ai</div>
<div class="navbar-right">
<span class="navbar-user" id="admin-name"></span>
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
</div>
</nav>
<div class="container" style="padding-top: 24px; padding-bottom: 48px;">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-panel="users-panel">Clients</button>
<button class="tab" data-panel="instructions-panel">Instructions</button>
<button class="tab" data-panel="voice-panel">Voice → Instruction</button>
</div>
<!-- Users Panel -->
<div id="users-panel" class="tab-panel">
<div class="flex-between mb-16">
<h2>Client Management</h2>
<button class="btn btn-primary btn-sm" onclick="showUserModal()">+ Add Client</button>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Status</th>
<th class="hide-mobile">Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Instructions Panel -->
<div id="instructions-panel" class="tab-panel hidden">
<div class="flex-between mb-16">
<h2>Instructions</h2>
<div class="flex gap-8">
<button class="btn btn-secondary btn-sm" onclick="uploadInstructionFile()">📁 Upload File</button>
<button class="btn btn-primary btn-sm" onclick="showInstructionModal()">+ Add</button>
</div>
</div>
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th class="hide-mobile">Client</th>
<th class="hide-mobile">Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="instructions-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Voice Instruction Panel -->
<div id="voice-panel" class="tab-panel hidden">
<h2 class="mb-16">Voice → Instruction Generator</h2>
<p class="text-muted text-sm mb-16">Record your teaching instructions or method using voice, and let AI
structure them into a reusable prompt instruction.</p>
<div class="card">
<div class="form-group">
<label>1. Record or type your raw instruction</label>
<div class="flex gap-8 mb-8">
<button class="btn btn-secondary" id="voice-record-btn" onclick="toggleVoiceRecord()">🎤 Start
Recording</button>
</div>
<textarea id="voice-raw-text" rows="6"
placeholder="Your raw instruction text will appear here after recording, or type directly..."></textarea>
</div>
<button class="btn btn-primary" id="voice-gen-btn" onclick="generateInstruction()">✨ Generate
Instruction</button>
<div id="voice-gen-output" class="hidden mt-24">
<div class="form-group">
<label>2. Review generated instruction</label>
<textarea id="voice-gen-text" rows="8"
placeholder="Generated instruction will appear here..."></textarea>
</div>
<div class="flex gap-8">
<button class="btn btn-primary" onclick="saveGeneratedInstruction()">💾 Save as
Instruction</button>
<button class="btn btn-secondary" onclick="generateInstruction()">🔄 Regenerate</button>
</div>
</div>
</div>
</div>
</div>
<!-- User Modal -->
<div id="user-modal" class="modal-overlay hidden">
<div class="modal">
<h2 id="user-modal-title">Add Client</h2>
<form id="user-form">
<div class="form-group">
<label for="user-username">Username</label>
<input type="text" id="user-username" required>
</div>
<div class="form-group">
<label for="user-email">Email</label>
<input type="email" id="user-email" required>
</div>
<div class="form-group">
<label for="user-password">Password</label>
<input type="password" id="user-password" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Instruction Modal -->
<div id="instruction-modal" class="modal-overlay hidden">
<div class="modal">
<h2 id="instr-modal-title">Add Instruction</h2>
<form id="instruction-form">
<div class="form-group">
<label for="instr-user">Client</label>
<select id="instr-user">
<option value="">Global (all clients)</option>
</select>
</div>
<div class="form-group">
<label for="instr-title">Title</label>
<input type="text" id="instr-title" required>
</div>
<div class="form-group">
<label for="instr-type">Type</label>
<select id="instr-type">
<option value="global">Global Method</option>
<option value="personal">Personal Instruction</option>
<option value="homework">Homework</option>
</select>
</div>
<div class="form-group">
<label for="instr-content">Content</label>
<textarea id="instr-content" rows="8" required></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeInstructionModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/voice.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>

46
frontend/chat.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Chat with your personal German tutor">
<title>FluentGerman.ai — Chat</title>
<link rel="stylesheet" href="/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
</head>
<body>
<!-- Navbar -->
<nav class="navbar">
<div class="logo">FluentGerman.ai</div>
<div class="navbar-right">
<span class="navbar-user" id="user-name"></span>
<button class="btn btn-sm btn-secondary" id="logout-btn">Logout</button>
</div>
</nav>
<!-- Chat -->
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
<div class="message message-assistant">
Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone
to speak.
</div>
</div>
<div class="chat-input-bar">
<button class="voice-btn" id="voice-btn" title="Voice input">🎤</button>
<input type="text" id="chat-input" placeholder="Type your message or click 🎤 to speak..."
autocomplete="off">
<button class="btn btn-primary" id="send-btn">Send</button>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/voice.js"></script>
<script src="/js/chat.js"></script>
</body>
</html>

942
frontend/css/style.css Normal file
View File

@@ -0,0 +1,942 @@
/* FluentGerman.ai — Design System v2 */
/* ── Fonts ────────────────────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* ── CSS Custom Properties ───────────────────────────────────────── */
:root {
/* Gradient palette */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-accent: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-bg: linear-gradient(135deg, #0c0e1a 0%, #141829 50%, #0f1423 100%);
--gradient-card: linear-gradient(145deg, rgba(30, 35, 55, 0.8), rgba(20, 25, 42, 0.6));
--gradient-btn: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-btn-hover: linear-gradient(135deg, #7b93ff 0%, #8a5cb8 100%);
--gradient-warm: linear-gradient(135deg, #f5af19 0%, #f12711 100%);
/* Colors */
--bg-primary: #0c0e1a;
--bg-secondary: #131726;
--bg-card: rgba(22, 27, 45, 0.7);
--bg-input: rgba(35, 42, 65, 0.6);
--bg-hover: rgba(45, 55, 80, 0.5);
--text-primary: #e8ecf5;
--text-secondary: #8b92a8;
--text-muted: #5a6178;
--accent: #7c6cf0;
--accent-hover: #8f82f5;
--accent-glow: rgba(124, 108, 240, 0.3);
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--border: rgba(255, 255, 255, 0.06);
--border-focus: rgba(124, 108, 240, 0.5);
/* Glass */
--glass-bg: rgba(22, 27, 45, 0.65);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 20px;
/* Spacing */
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 30px rgba(124, 108, 240, 0.15);
--shadow-btn-glow: 0 4px 24px rgba(124, 108, 240, 0.35);
/* Transitions */
--transition: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* ── Reset & Base ─────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--gradient-bg);
background-attachment: fixed;
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
/* Animated background orbs */
body::before,
body::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.12;
z-index: -1;
pointer-events: none;
animation: orbFloat 20s ease-in-out infinite alternate;
}
body::before {
width: 600px;
height: 600px;
background: var(--gradient-primary);
top: -200px;
right: -200px;
}
body::after {
width: 500px;
height: 500px;
background: var(--gradient-accent);
bottom: -150px;
left: -150px;
animation-delay: -10s;
}
a {
color: var(--accent);
text-decoration: none;
transition: color var(--transition);
}
a:hover {
color: var(--accent-hover);
}
/* ── Layout ───────────────────────────────────────────────────────── */
.container {
max-width: 940px;
margin: 0 auto;
padding: 0 20px;
}
.page-center {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
/* ── Glass Cards ──────────────────────────────────────────────────── */
.card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-md), var(--shadow-glow);
transition: transform var(--transition), box-shadow var(--transition);
}
.card:hover {
box-shadow: var(--shadow-lg), var(--shadow-glow);
}
.card-sm {
max-width: 420px;
width: 100%;
}
/* ── Typography ───────────────────────────────────────────────────── */
h1,
h2,
h3 {
font-weight: 600;
letter-spacing: -0.02em;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.35rem;
}
h3 {
font-size: 1.1rem;
}
.logo {
font-size: 1.6rem;
font-weight: 700;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.03em;
filter: drop-shadow(0 0 12px rgba(124, 108, 240, 0.3));
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 6px;
}
/* ── Forms ────────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
letter-spacing: 0.02em;
}
input,
select,
textarea {
width: 100%;
padding: 11px 16px;
background: var(--bg-input);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
transition: all var(--transition);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-glow), 0 0 20px rgba(124, 108, 240, 0.1);
background: rgba(40, 48, 75, 0.7);
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}
textarea {
resize: vertical;
min-height: 100px;
}
/* ── Buttons ──────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 22px;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
position: relative;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(rgba(255, 255, 255, 0.1), transparent);
opacity: 0;
transition: opacity var(--transition);
}
.btn:hover::before {
opacity: 1;
}
.btn-primary {
background: var(--gradient-btn);
color: #fff;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.25);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-btn-glow);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
}
.btn-secondary {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--glass-border);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.12);
transform: translateY(-1px);
}
.btn-danger {
background: linear-gradient(135deg, #fb7185, #e11d48);
color: #fff;
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(251, 113, 133, 0.3);
}
.btn-sm {
padding: 7px 14px;
font-size: 0.8rem;
border-radius: 8px;
}
.btn-block {
width: 100%;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ── Navbar ───────────────────────────────────────────────────────── */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-user {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 4px;
padding: 4px;
background: rgba(20, 25, 42, 0.6);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin-bottom: 24px;
}
.tab {
flex: 1;
padding: 9px 16px;
background: transparent;
border: none;
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
text-align: center;
position: relative;
}
.tab.active {
background: var(--gradient-btn);
color: #fff;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.3);
}
.tab:hover:not(.active) {
color: var(--text-primary);
background: rgba(124, 108, 240, 0.1);
}
/* ── Chat ─────────────────────────────────────────────────────────── */
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 57px);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.message {
max-width: 75%;
padding: 13px 18px;
border-radius: var(--radius-md);
font-size: 0.94rem;
line-height: 1.55;
animation: messageIn 0.4s var(--transition-bounce) backwards;
position: relative;
}
.message-user {
align-self: flex-end;
background: var(--gradient-btn);
color: #fff;
border-bottom-right-radius: 5px;
box-shadow: 0 2px 12px rgba(124, 108, 240, 0.2);
}
.message-assistant {
align-self: flex-start;
background: var(--glass-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-bottom-left-radius: 5px;
box-shadow: var(--shadow-sm);
}
/* ── Markdown in messages ──────────────────────────────────────────── */
.message-assistant p {
margin: 0 0 8px 0;
}
.message-assistant p:last-child {
margin-bottom: 0;
}
.message-assistant strong {
color: #c4b5fd;
}
.message-assistant code {
background: rgba(124, 108, 240, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.86em;
}
.message-assistant pre {
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
margin: 8px 0;
}
.message-assistant pre code {
background: none;
padding: 0;
font-size: 0.85em;
color: #d4d4d8;
}
.message-assistant ul,
.message-assistant ol {
margin: 6px 0;
padding-left: 20px;
}
.message-assistant li {
margin-bottom: 3px;
}
.message-assistant blockquote {
border-left: 3px solid var(--accent);
padding: 4px 12px;
margin: 8px 0;
color: var(--text-secondary);
background: rgba(124, 108, 240, 0.05);
border-radius: 0 6px 6px 0;
}
.message-assistant h1,
.message-assistant h2,
.message-assistant h3 {
margin: 10px 0 6px 0;
font-size: 1em;
color: #e0d4ff;
}
.message-assistant hr {
border: none;
border-top: 1px solid var(--border);
margin: 10px 0;
}
.message-assistant table {
margin: 8px 0;
font-size: 0.85em;
}
.message-assistant a {
color: var(--accent-hover);
text-decoration: underline;
text-decoration-style: dotted;
}
.chat-input-bar {
display: flex;
gap: 10px;
padding: 16px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border-top: 1px solid var(--glass-border);
}
.chat-input-bar input {
flex: 1;
}
.voice-btn {
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--bg-input);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.voice-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.05);
}
.voice-btn.recording {
background: linear-gradient(135deg, #fb7185, #e11d48);
color: #fff;
border-color: transparent;
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
animation: recordPulse 1.8s infinite;
}
/* ── Tables ───────────────────────────────────────────────────────── */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
tr {
transition: background var(--transition);
}
tr:hover td {
background: rgba(124, 108, 240, 0.04);
}
/* ── Badge ────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.badge-global {
background: rgba(124, 108, 240, 0.15);
color: #a99bff;
}
.badge-personal {
background: rgba(52, 211, 153, 0.15);
color: #6ee7b7;
}
.badge-homework {
background: rgba(251, 191, 36, 0.15);
color: #fcd34d;
}
/* ── Modal ────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
display: flex;
justify-content: center;
align-items: center;
z-index: 200;
padding: 20px;
animation: fadeIn 0.25s ease;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 30px;
width: 100%;
max-width: 500px;
box-shadow: var(--shadow-lg), 0 0 60px rgba(124, 108, 240, 0.08);
animation: modalIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal h2 {
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
/* ── Toast ────────────────────────────────────────────────────────── */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: var(--shadow-md);
backdrop-filter: blur(12px);
}
.toast-success {
background: linear-gradient(135deg, rgba(52, 211, 153, 0.9), rgba(16, 185, 129, 0.9));
color: #0c2a1c;
}
.toast-error {
background: linear-gradient(135deg, rgba(251, 113, 133, 0.9), rgba(225, 29, 72, 0.9));
color: #fff;
}
/* ── Animations ───────────────────────────────────────────────────── */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(24px) scale(0.9);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes recordPulse {
0% {
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.5);
}
50% {
box-shadow: 0 0 0 14px rgba(251, 113, 133, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0);
}
}
@keyframes orbFloat {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-40px, 30px) scale(1.1);
}
100% {
transform: translate(20px, -20px) scale(0.95);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 768px) {
.card {
padding: 24px;
}
.chat-messages {
padding: 16px;
}
.chat-input-bar {
padding: 12px 16px;
}
.message {
max-width: 88%;
}
.navbar {
padding: 12px 16px;
}
.modal {
padding: 22px;
}
table {
font-size: 0.8rem;
}
th,
td {
padding: 10px 12px;
}
.logo {
font-size: 1.3rem;
}
body::before,
body::after {
display: none;
}
.hide-mobile {
display: none !important;
}
}
/* ── Utilities ────────────────────────────────────────────────────── */
.mt-8 {
margin-top: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.mb-8 {
margin-bottom: 8px;
}
.mb-16 {
margin-bottom: 16px;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--text-muted);
}
.text-sm {
font-size: 0.85rem;
}
.flex {
display: flex;
}
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.gap-8 {
gap: 8px;
}
.gap-16 {
gap: 16px;
}
.hidden {
display: none !important;
}
/* ── Scrollbar ────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(124, 108, 240, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 240, 0.4);
}
/* ── Voice status & indicators ────────────────────────────────────── */
.voice-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--danger);
font-weight: 500;
}
.voice-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
animation: recordPulse 1.5s infinite;
}
/* ── Empty state ──────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
/* ── Loading spinner ──────────────────────────────────────────────── */
.spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ── Selection highlight ──────────────────────────────────────────── */
::selection {
background: rgba(124, 108, 240, 0.35);
color: #fff;
}

36
frontend/index.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="FluentGerman.ai — Your personal AI-powered German language tutor">
<title>FluentGerman.ai — Login</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="page-center">
<div class="card card-sm">
<div class="text-center mb-16">
<div class="logo">FluentGerman.ai</div>
<p class="subtitle">Your personal AI German tutor</p>
</div>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" placeholder="Enter your username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required>
</div>
<div id="login-error" class="text-sm hidden" style="color: var(--danger); margin-bottom: 12px;"></div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
</body>
</html>

341
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,341 @@
/* FluentGerman.ai — Admin panel logic */
document.addEventListener('DOMContentLoaded', () => {
if (!requireAuth() || !requireAdmin()) return;
const user = getUser();
document.getElementById('admin-name').textContent = user?.username || 'Admin';
// Tab switching
const tabs = document.querySelectorAll('.tab');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.add('hidden'));
tab.classList.add('active');
document.getElementById(tab.dataset.panel).classList.remove('hidden');
});
});
// ── Users ──────────────────────────────────────────────────────
const usersBody = document.getElementById('users-body');
const userModal = document.getElementById('user-modal');
const userForm = document.getElementById('user-form');
let editingUserId = null;
async function loadUsers() {
try {
const users = await apiJSON('/users/');
usersBody.innerHTML = '';
if (users.length === 0) {
usersBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No clients yet</td></tr>';
return;
}
users.forEach(u => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${u.username}</td>
<td class="hide-mobile">${u.email}</td>
<td><span class="badge ${u.is_active ? 'badge-personal' : 'badge-homework'}">${u.is_active ? 'Active' : 'Inactive'}</span></td>
<td class="hide-mobile">${new Date(u.created_at).toLocaleDateString()}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editUser(${u.id})">Edit</button>
<button class="btn btn-sm btn-secondary" onclick="manageInstructions(${u.id}, '${u.username}')">📝</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">✕</button>
</td>
`;
usersBody.appendChild(row);
});
} catch (e) {
showToast(e.message, 'error');
}
}
window.showUserModal = (editing = false) => {
editingUserId = null;
document.getElementById('user-modal-title').textContent = 'Add Client';
userForm.reset();
document.getElementById('user-password').required = true;
userModal.classList.remove('hidden');
};
window.closeUserModal = () => {
userModal.classList.add('hidden');
editingUserId = null;
};
window.editUser = async (id) => {
try {
const users = await apiJSON('/users/');
const u = users.find(x => x.id === id);
if (!u) return;
editingUserId = id;
document.getElementById('user-modal-title').textContent = 'Edit Client';
document.getElementById('user-username').value = u.username;
document.getElementById('user-email').value = u.email;
document.getElementById('user-password').value = '';
document.getElementById('user-password').required = false;
userModal.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
};
userForm.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
username: document.getElementById('user-username').value.trim(),
email: document.getElementById('user-email').value.trim(),
};
const password = document.getElementById('user-password').value;
if (password) data.password = password;
try {
if (editingUserId) {
await apiJSON(`/users/${editingUserId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
showToast('Client updated');
} else {
data.password = password;
await apiJSON('/users/', {
method: 'POST',
body: JSON.stringify(data),
});
showToast('Client created');
}
closeUserModal();
loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
});
window.deleteUser = async (id) => {
if (!confirm('Delete this client? This will also remove all their instructions.')) return;
try {
await api(`/users/${id}`, { method: 'DELETE' });
showToast('Client deleted');
loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
};
// ── Instructions ───────────────────────────────────────────────
const instructionsBody = document.getElementById('instructions-body');
const instrModal = document.getElementById('instruction-modal');
const instrForm = document.getElementById('instruction-form');
const instrUserSelect = document.getElementById('instr-user');
let editingInstrId = null;
let currentFilterUserId = null;
async function loadInstructions(userId = null) {
try {
const url = userId ? `/instructions/?user_id=${userId}` : '/instructions/';
const instructions = await apiJSON(url);
instructionsBody.innerHTML = '';
if (instructions.length === 0) {
instructionsBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No instructions yet</td></tr>';
return;
}
instructions.forEach(inst => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${inst.title}</td>
<td><span class="badge badge-${inst.type}">${inst.type}</span></td>
<td class="hide-mobile">${inst.user_id || 'Global'}</td>
<td class="hide-mobile">${inst.content.substring(0, 60)}${inst.content.length > 60 ? '...' : ''}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="editInstruction(${inst.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteInstruction(${inst.id})">✕</button>
</td>
`;
instructionsBody.appendChild(row);
});
} catch (e) {
showToast(e.message, 'error');
}
}
async function loadUserOptions() {
try {
const users = await apiJSON('/users/');
instrUserSelect.innerHTML = '<option value="">Global (all clients)</option>';
users.forEach(u => {
instrUserSelect.innerHTML += `<option value="${u.id}">${u.username}</option>`;
});
} catch (e) {
// silently fail
}
}
window.manageInstructions = (userId, username) => {
currentFilterUserId = userId;
// Switch to instructions tab
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.add('hidden'));
document.querySelector('[data-panel="instructions-panel"]').classList.add('active');
document.getElementById('instructions-panel').classList.remove('hidden');
loadInstructions(userId);
};
window.showInstructionModal = () => {
editingInstrId = null;
document.getElementById('instr-modal-title').textContent = 'Add Instruction';
instrForm.reset();
loadUserOptions();
instrModal.classList.remove('hidden');
};
window.closeInstructionModal = () => {
instrModal.classList.add('hidden');
editingInstrId = null;
};
window.editInstruction = async (id) => {
try {
const instructions = await apiJSON('/instructions/');
const inst = instructions.find(x => x.id === id);
if (!inst) return;
editingInstrId = id;
await loadUserOptions();
document.getElementById('instr-modal-title').textContent = 'Edit Instruction';
document.getElementById('instr-title').value = inst.title;
document.getElementById('instr-content').value = inst.content;
document.getElementById('instr-type').value = inst.type;
instrUserSelect.value = inst.user_id || '';
instrModal.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
};
instrForm.addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
title: document.getElementById('instr-title').value.trim(),
content: document.getElementById('instr-content').value.trim(),
type: document.getElementById('instr-type').value,
user_id: instrUserSelect.value ? parseInt(instrUserSelect.value) : null,
};
try {
if (editingInstrId) {
await apiJSON(`/instructions/${editingInstrId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
showToast('Instruction updated');
} else {
await apiJSON('/instructions/', {
method: 'POST',
body: JSON.stringify(data),
});
showToast('Instruction created');
}
closeInstructionModal();
loadInstructions(currentFilterUserId);
} catch (e) {
showToast(e.message, 'error');
}
});
window.deleteInstruction = async (id) => {
if (!confirm('Delete this instruction?')) return;
try {
await api(`/instructions/${id}`, { method: 'DELETE' });
showToast('Instruction deleted');
loadInstructions(currentFilterUserId);
} catch (e) {
showToast(e.message, 'error');
}
};
// ── File upload for instructions ────────────────────────────────
window.uploadInstructionFile = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.txt,.md';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
document.getElementById('instr-title').value = file.name.replace(/\.[^.]+$/, '');
document.getElementById('instr-content').value = text;
showInstructionModal();
};
input.click();
};
// ── Voice instruction generation ───────────────────────────────
const voiceGenBtn = document.getElementById('voice-gen-btn');
const voiceGenOutput = document.getElementById('voice-gen-output');
const voiceGenText = document.getElementById('voice-gen-text');
const voice = new VoiceManager();
voice.init().then(() => {
voice.onResult = (text) => {
document.getElementById('voice-raw-text').value = text;
};
voice.onStateChange = (recording) => {
const btn = document.getElementById('voice-record-btn');
btn.classList.toggle('recording', recording);
btn.textContent = recording ? '⏹ Stop Recording' : '🎤 Start Recording';
};
});
window.toggleVoiceRecord = () => voice.toggleRecording();
window.generateInstruction = async () => {
const rawText = document.getElementById('voice-raw-text').value.trim();
if (!rawText) {
showToast('Please record or type some text first', 'error');
return;
}
voiceGenBtn.disabled = true;
voiceGenBtn.textContent = 'Generating...';
try {
const result = await apiJSON('/voice/generate-instruction', {
method: 'POST',
body: JSON.stringify({ raw_text: rawText }),
});
voiceGenText.value = result.instruction;
voiceGenOutput.classList.remove('hidden');
} catch (e) {
showToast(e.message, 'error');
}
voiceGenBtn.disabled = false;
voiceGenBtn.textContent = '✨ Generate Instruction';
};
window.saveGeneratedInstruction = () => {
const content = voiceGenText.value.trim();
if (!content) return;
loadUserOptions();
document.getElementById('instr-content').value = content;
document.getElementById('instr-title').value = 'Voice Generated Instruction';
showInstructionModal();
};
// ── Init ───────────────────────────────────────────────────────
loadUsers();
loadInstructions();
document.getElementById('logout-btn').addEventListener('click', logout);
});

105
frontend/js/api.js Normal file
View File

@@ -0,0 +1,105 @@
/* FluentGerman.ai — API client with auth */
const API_BASE = '/api';
function getToken() {
return localStorage.getItem('fg_token');
}
function setToken(token) {
localStorage.setItem('fg_token', token);
}
function clearToken() {
localStorage.removeItem('fg_token');
}
function getUser() {
const data = localStorage.getItem('fg_user');
return data ? JSON.parse(data) : null;
}
function setUser(user) {
localStorage.setItem('fg_user', JSON.stringify(user));
}
function clearUser() {
localStorage.removeItem('fg_user');
}
async function api(path, options = {}) {
const token = getToken();
const headers = { ...options.headers };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Don't set Content-Type for FormData (browser sets it with boundary)
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (response.status === 401) {
clearToken();
clearUser();
window.location.href = '/';
return;
}
return response;
}
async function apiJSON(path, options = {}) {
const response = await api(path, options);
if (!response || !response.ok) {
const error = await response?.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
function requireAuth() {
if (!getToken()) {
window.location.href = '/';
return false;
}
return true;
}
function requireAdmin() {
const user = getUser();
if (!user?.is_admin) {
window.location.href = '/chat.html';
return false;
}
return true;
}
function logout() {
clearToken();
clearUser();
window.location.href = '/';
}
/* Toast notifications */
function showToast(message, type = 'success') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}

56
frontend/js/auth.js Normal file
View File

@@ -0,0 +1,56 @@
/* FluentGerman.ai — Auth page logic */
document.addEventListener('DOMContentLoaded', () => {
// If already logged in, redirect
const token = getToken();
const user = getUser();
if (token && user) {
window.location.href = user.is_admin ? '/admin.html' : '/chat.html';
return;
}
const form = document.getElementById('login-form');
const errorEl = document.getElementById('login-error');
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.classList.add('hidden');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) {
errorEl.textContent = 'Please fill in all fields.';
errorEl.classList.remove('hidden');
return;
}
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Login failed');
}
const data = await response.json();
setToken(data.access_token);
// Fetch user info
const meResp = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${data.access_token}` },
});
const userData = await meResp.json();
setUser(userData);
window.location.href = userData.is_admin ? '/admin.html' : '/chat.html';
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
}
});
});

153
frontend/js/chat.js Normal file
View File

@@ -0,0 +1,153 @@
/* FluentGerman.ai — Chat interface logic */
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub-flavored markdown
});
/**
* Render markdown text to sanitized HTML.
*/
function renderMarkdown(text) {
const raw = marked.parse(text);
return DOMPurify.sanitize(raw);
}
document.addEventListener('DOMContentLoaded', async () => {
if (!requireAuth()) return;
const user = getUser();
document.getElementById('user-name').textContent = user?.username || 'User';
const messagesEl = document.getElementById('chat-messages');
const inputEl = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const voiceBtn = document.getElementById('voice-btn');
let history = [];
// Init voice
const voice = new VoiceManager();
await voice.init();
voice.onResult = (text) => {
inputEl.value = text;
voice.lastInputWasVoice = true;
sendMessage();
};
voice.onStateChange = (recording) => {
voiceBtn.classList.toggle('recording', recording);
voiceBtn.textContent = recording ? '⏹' : '🎤';
};
voiceBtn.addEventListener('click', () => voice.toggleRecording());
// Chat
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = `message message-${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
} else {
div.textContent = content;
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text) return;
// Capture whether this was a voice input BEFORE clearing
const wasVoice = voice.lastInputWasVoice;
voice.lastInputWasVoice = false;
inputEl.value = '';
sendBtn.disabled = true;
appendMessage('user', text);
history.push({ role: 'user', content: text });
// Create assistant message placeholder
const assistantEl = appendMessage('assistant', '');
let fullResponse = '';
try {
const response = await api('/chat/', {
method: 'POST',
body: JSON.stringify({ message: text, history: history.slice(-20) }),
});
if (!response?.ok) {
const errData = await response?.json().catch(() => ({}));
throw new Error(errData.detail || `Chat failed (${response?.status})`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.token) {
fullResponse += parsed.token;
// Live-render markdown as tokens stream in
assistantEl.innerHTML = renderMarkdown(fullResponse);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (parsed.error) {
showToast(parsed.error, 'error');
}
} catch (e) {
// skip unparseable chunks
}
}
}
}
if (fullResponse) {
history.push({ role: 'assistant', content: fullResponse });
// Auto-speak response if the user used voice input
if (wasVoice) {
await voice.speak(fullResponse);
}
}
} catch (e) {
assistantEl.textContent = 'Sorry, something went wrong. Please try again.';
showToast(e.message, 'error');
console.error('[Chat] Error:', e);
}
sendBtn.disabled = false;
inputEl.focus();
}
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Logout
document.getElementById('logout-btn').addEventListener('click', logout);
});

230
frontend/js/voice.js Normal file
View File

@@ -0,0 +1,230 @@
/* FluentGerman.ai — Voice module (Web Speech API + API mode) */
class VoiceManager {
constructor() {
this.mode = 'browser'; // will be set from server config
this.recognition = null;
this.synthesis = window.speechSynthesis;
this.isRecording = false;
this.lastInputWasVoice = false; // tracks if last message was spoken
this.mediaRecorder = null;
this.audioChunks = [];
this.onResult = null;
this.onStateChange = null;
}
async init() {
// Always init browser STT as fallback
this._initBrowserSTT();
// Fetch voice mode from server
try {
const response = await api('/voice/config');
if (response?.ok) {
const config = await response.json();
this.mode = config.voice_mode;
console.log('[Voice] Mode:', this.mode);
}
} catch (e) {
console.warn('[Voice] Could not fetch config, using browser mode');
this.mode = 'browser';
}
}
_initBrowserSTT() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('[Voice] Speech recognition not supported in this browser');
return;
}
this.recognition = new SpeechRecognition();
this.recognition.continuous = false;
this.recognition.interimResults = false;
this.recognition.lang = 'de-DE';
this.recognition.onresult = (event) => {
const text = event.results[0][0].transcript;
console.log('[Voice] Browser STT result:', text);
this.lastInputWasVoice = true;
if (this.onResult) this.onResult(text);
};
this.recognition.onend = () => {
console.log('[Voice] Browser STT ended');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
};
this.recognition.onerror = (event) => {
console.error('[Voice] Browser STT error:', event.error);
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
if (event.error === 'not-allowed') {
showToast('Microphone access denied. Please allow microphone in browser settings.', 'error');
} else if (event.error === 'no-speech') {
showToast('No speech detected. Try again.', 'error');
}
};
console.log('[Voice] Browser STT initialized');
}
async startRecording() {
this.isRecording = true;
this.lastInputWasVoice = true;
if (this.onStateChange) this.onStateChange(true);
if (this.mode === 'api') {
// API mode — record audio via MediaRecorder, send to Whisper
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.audioChunks = [];
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop());
const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
await this._transcribeAPI(blob);
};
this.mediaRecorder.start();
console.log('[Voice] API recording started');
} catch (e) {
console.error('[Voice] Microphone access error:', e);
showToast('Microphone access denied', 'error');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
} else {
// Browser mode — use Web Speech API
if (this.recognition) {
try {
this.recognition.start();
console.log('[Voice] Browser STT started');
} catch (e) {
console.error('[Voice] Failed to start recognition:', e);
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
showToast('Voice recognition failed to start. Try again.', 'error');
}
} else {
console.warn('[Voice] No speech recognition available');
showToast('Speech recognition not supported in this browser', 'error');
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
}
stopRecording() {
console.log('[Voice] Stopping recording...');
if (this.mode === 'api') {
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop();
} else {
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
} else {
if (this.recognition) {
try {
this.recognition.stop();
} catch (e) {
// Already stopped
}
}
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
async _transcribeAPI(blob) {
try {
const formData = new FormData();
formData.append('audio', blob, 'recording.webm');
console.log('[Voice] Sending audio to API for transcription...');
const response = await api('/voice/transcribe', {
method: 'POST',
body: formData,
});
if (response?.ok) {
const data = await response.json();
console.log('[Voice] API transcription result:', data.text);
this.lastInputWasVoice = true;
if (this.onResult) this.onResult(data.text);
} else {
showToast('Transcription failed. Falling back to browser voice.', 'error');
// Fallback: switch to browser mode for this session
this.mode = 'browser';
}
} catch (e) {
console.error('[Voice] API transcription error:', e);
showToast('Transcription error', 'error');
} finally {
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
}
}
async speak(text) {
if (this.mode === 'api') {
return this._speakAPI(text);
} else {
return this._speakBrowser(text);
}
}
_speakBrowser(text) {
return new Promise((resolve) => {
// Cancel any ongoing speech
this.synthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'de-DE';
utterance.rate = 0.95;
utterance.onend = resolve;
utterance.onerror = () => {
console.warn('[Voice] Browser TTS error');
resolve();
};
this.synthesis.speak(utterance);
});
}
async _speakAPI(text) {
try {
const response = await api(`/voice/synthesize?text=${encodeURIComponent(text)}`, {
method: 'POST',
});
if (response?.ok) {
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
await audio.play();
return new Promise(resolve => {
audio.onended = resolve;
});
}
} catch (e) {
console.warn('[Voice] API TTS failed, falling back to browser');
}
// Fallback to browser TTS
return this._speakBrowser(text);
}
toggleRecording() {
if (this.isRecording) {
this.stopRecording();
} else {
this.startRecording();
}
}
}