-
- Hallo! 👋 I'm your personal German tutor. How can I help you today? You can type or use the microphone
- to speak.
+
+
+
+
+
+
Hallo! 👋
+
Your personal AI German tutor
+
+
+
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 31dc0d0..eacda03 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -687,6 +687,155 @@ tr:hover td {
color: #fff;
}
+.toast-info {
+ background: linear-gradient(135deg, rgba(96, 165, 250, 0.9), rgba(59, 130, 246, 0.9));
+ color: #fff;
+}
+
+/* ── Welcome Section ─────────────────────────────────────────────── */
+.welcome-section {
+ background: var(--gradient-card);
+ 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: 28px 32px;
+ margin-bottom: 8px;
+ box-shadow: var(--shadow-md), var(--shadow-glow);
+ animation: messageIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
+}
+
+.welcome-row {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.welcome-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.welcome-greeting {
+ font-size: 1.4rem;
+ font-weight: 700;
+ background: var(--gradient-primary);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ letter-spacing: -0.02em;
+ margin-bottom: 4px;
+}
+
+.welcome-subtitle {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ margin-bottom: 8px;
+}
+
+.welcome-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ opacity: 0;
+ transform: translateY(6px);
+ transition: opacity 0.4s ease, transform 0.4s ease;
+}
+
+.welcome-meta.visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.welcome-meta strong {
+ color: var(--text-secondary);
+}
+
+.meta-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--success);
+ flex-shrink: 0;
+ box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
+}
+
+/* ── Avatar ───────────────────────────────────────────────────────── */
+.avatar-container {
+ position: relative;
+ width: 72px;
+ height: 72px;
+ flex-shrink: 0;
+}
+
+.avatar-ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ background: var(--gradient-primary) border-box;
+ -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ animation: avatarSpin 6s linear infinite;
+}
+
+.avatar-placeholder {
+ position: absolute;
+ inset: 4px;
+ border-radius: 50%;
+ background: var(--glass-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.8rem;
+ animation: avatarBreathe 3s ease-in-out infinite;
+ box-shadow: inset 0 0 20px rgba(124, 108, 240, 0.08);
+}
+
+.avatar-pulse {
+ position: absolute;
+ inset: -4px;
+ border-radius: 50%;
+ border: 1px solid rgba(124, 108, 240, 0.15);
+ animation: avatarPulseAnim 3s ease-in-out infinite;
+}
+
+@keyframes avatarSpin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes avatarBreathe {
+
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+
+ 50% {
+ transform: scale(1.03);
+ }
+}
+
+@keyframes avatarPulseAnim {
+
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 0.5;
+ }
+
+ 50% {
+ transform: scale(1.12);
+ opacity: 0;
+ }
+}
+
/* ── Animations ───────────────────────────────────────────────────── */
@keyframes fadeIn {
from {
@@ -825,6 +974,27 @@ tr:hover td {
.hide-mobile {
display: none !important;
}
+
+ .welcome-section {
+ padding: 20px;
+ }
+
+ .welcome-row {
+ gap: 16px;
+ }
+
+ .avatar-container {
+ width: 56px;
+ height: 56px;
+ }
+
+ .avatar-placeholder {
+ font-size: 1.4rem;
+ }
+
+ .welcome-greeting {
+ font-size: 1.15rem;
+ }
}
/* ── Utilities ────────────────────────────────────────────────────── */
diff --git a/frontend/js/chat.js b/frontend/js/chat.js
index 75699b9..2e5989c 100644
--- a/frontend/js/chat.js
+++ b/frontend/js/chat.js
@@ -14,11 +14,32 @@ function renderMarkdown(text) {
return DOMPurify.sanitize(raw);
}
+/**
+ * Format a date as a friendly relative time string.
+ */
+function relativeTime(dateStr) {
+ if (!dateStr) return null;
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'just now';
+ if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
+ if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
+ if (diffDays === 1) return 'yesterday';
+ if (diffDays < 30) return `${diffDays} days ago`;
+ return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
+}
+
document.addEventListener('DOMContentLoaded', async () => {
if (!requireAuth()) return;
const user = getUser();
- document.getElementById('user-name').textContent = user?.username || 'User';
+ const displayName = user?.username || 'User';
+ document.getElementById('user-name').textContent = displayName;
const messagesEl = document.getElementById('chat-messages');
const inputEl = document.getElementById('chat-input');
@@ -27,7 +48,36 @@ document.addEventListener('DOMContentLoaded', async () => {
let history = [];
- // Init voice
+ // ── Personalised welcome ──────────────────────────────────────────
+ const greetingEl = document.getElementById('welcome-greeting');
+ const subtitleEl = document.getElementById('welcome-subtitle');
+ const metaEl = document.getElementById('welcome-meta');
+
+ // Immediately show the username we already have from localStorage
+ greetingEl.textContent = `Hallo, ${displayName}! 👋`;
+
+ // Fetch dashboard info for latest instruction data
+ try {
+ const resp = await api('/chat/dashboard');
+ if (resp?.ok) {
+ const data = await resp.json();
+ // Use server username (authoritative)
+ greetingEl.textContent = `Hallo, ${data.username}! 👋`;
+
+ if (data.latest_instruction_at) {
+ const ago = relativeTime(data.latest_instruction_at);
+ metaEl.innerHTML = `
Lessons last updated
${ago}`;
+ metaEl.classList.add('visible');
+ } else {
+ metaEl.textContent = 'No custom lessons configured yet';
+ metaEl.classList.add('visible');
+ }
+ }
+ } catch (e) {
+ console.warn('[Chat] Could not fetch dashboard info:', e);
+ }
+
+ // ── Voice ─────────────────────────────────────────────────────────
const voice = new VoiceManager();
await voice.init();
@@ -44,7 +94,7 @@ document.addEventListener('DOMContentLoaded', async () => {
voiceBtn.addEventListener('click', () => voice.toggleRecording());
- // Chat
+ // ── Chat ──────────────────────────────────────────────────────────
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = `message message-${role}`;
diff --git a/frontend/js/voice.js b/frontend/js/voice.js
index 33c8ef1..4c2526d 100644
--- a/frontend/js/voice.js
+++ b/frontend/js/voice.js
@@ -11,10 +11,11 @@ class VoiceManager {
this.audioChunks = [];
this.onResult = null;
this.onStateChange = null;
+ this.browserSTTSupported = false;
}
async init() {
- // Always init browser STT as fallback
+ // Check browser STT support
this._initBrowserSTT();
// Fetch voice mode from server
@@ -23,21 +24,32 @@ class VoiceManager {
if (response?.ok) {
const config = await response.json();
this.mode = config.voice_mode;
- console.log('[Voice] Mode:', this.mode);
+ console.log('[Voice] Server mode:', this.mode);
}
} catch (e) {
console.warn('[Voice] Could not fetch config, using browser mode');
this.mode = 'browser';
}
+
+ // Auto-fallback: if server says "browser" but browser doesn't support STT, use API
+ if (this.mode === 'browser' && !this.browserSTTSupported) {
+ console.warn('[Voice] Browser STT not supported, falling back to API mode');
+ this.mode = 'api';
+ showToast('Using cloud voice recognition — your browser doesn\'t support built-in speech recognition.', 'info');
+ }
+
+ console.log('[Voice] Active mode:', this.mode);
}
_initBrowserSTT() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
- console.warn('[Voice] Speech recognition not supported in this browser');
+ console.warn('[Voice] SpeechRecognition API not available in this browser');
+ this.browserSTTSupported = false;
return;
}
+ this.browserSTTSupported = true;
this.recognition = new SpeechRecognition();
this.recognition.continuous = false;
this.recognition.interimResults = false;
@@ -114,10 +126,12 @@ class VoiceManager {
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');
+ // Shouldn't happen after init() fallback, but safety net
+ console.warn('[Voice] No speech recognition available, switching to API');
+ this.mode = 'api';
this.isRecording = false;
if (this.onStateChange) this.onStateChange(false);
+ showToast('Switched to cloud voice recognition. Please try again.', 'info');
}
}
}
@@ -161,9 +175,7 @@ class VoiceManager {
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';
+ showToast('Transcription failed. Please try again.', 'error');
}
} catch (e) {
console.error('[Voice] API transcription error:', e);