From 9a7e3f42d2a03e878fd631b01fc98239f64baee4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 24 Mar 2026 16:39:31 +0100 Subject: [PATCH] unified data completed implementation --- src/api/commcoachApi.ts | 71 -- src/components/OnboardingAssistant.tsx | 2 +- src/pages/Settings.tsx | 608 ++++++++++-------- .../views/commcoach/CommcoachSettingsView.tsx | 163 +---- .../WorkspaceGeneralSettings.module.css | 16 + .../workspace/WorkspaceGeneralSettings.tsx | 2 +- src/pages/views/workspace/WorkspaceInput.tsx | 18 +- .../workspace/WorkspaceSettings.module.css | 173 ----- .../views/workspace/WorkspaceSettings.tsx | 280 -------- .../views/workspace/WorkspaceSettingsPage.tsx | 13 +- 10 files changed, 393 insertions(+), 953 deletions(-) create mode 100644 src/pages/views/workspace/WorkspaceGeneralSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.tsx diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index ef9b0be..47f5665 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -50,18 +50,6 @@ export interface CoachingPersona { isActive: boolean; } -export interface CoachingDocument { - id: string; - contextId: string; - fileName: string; - mimeType: string; - fileSize: number; - extractedText?: string; - summary?: string; - fileRef?: string; - createdAt?: string; -} - export interface CoachingBadge { id: string; userId: string; @@ -110,8 +98,6 @@ export interface CoachingScore { export interface CoachingUserProfile { id: string; userId: string; - preferredLanguage: string; - preferredVoice?: string; dailyReminderTime?: string; dailyReminderEnabled: boolean; emailSummaryEnabled: boolean; @@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId: return data.profile; } -// ============================================================================ -// Voice API -// ============================================================================ - -export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' }); - return data.languages || []; -} - -export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } }); - return data.voices || []; -} - -export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: { - text?: string; language?: string; voiceId?: string; -}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); - return data; -} - // ============================================================================ // Persona API (Iteration 2) // ============================================================================ @@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId: await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); } -// ============================================================================ -// Document API (Iteration 2) -// ============================================================================ - -export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' }); - return data.documents || []; -} - -export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise { - const baseURL = api.defaults.baseURL || ''; - const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`; - const formData = new FormData(); - formData.append('file', file); - - const headers: Record = {}; - const authToken = localStorage.getItem('authToken'); - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); - if (pathMatch) { - headers['X-Mandate-Id'] = pathMatch[1]; - headers['X-Instance-Id'] = pathMatch[3]; - } - if (!getCSRFToken()) generateAndStoreCSRFToken(); - addCSRFTokenToHeaders(headers); - - const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' }); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); - const data = await response.json(); - return data.document; -} - -export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise { - await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); -} - // ============================================================================ // Badge API (Iteration 2) // ============================================================================ diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx index 97bd5b0..0ebf492 100644 --- a/src/components/OnboardingAssistant.tsx +++ b/src/components/OnboardingAssistant.tsx @@ -23,7 +23,7 @@ const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; const OnboardingAssistant: React.FC = ({ instanceId, mandateId, - featureCode, + featureCode: _featureCode, onDismiss, }) => { const navigate = useNavigate(); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 12bdec7..8a8ae74 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,18 +1,31 @@ /** - * Settings Page - * - * Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). + * Settings Page — User-level settings with tabs. + * Route: /settings */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useCurrentUser, useUser } from '../hooks/useUsers'; import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; +import { useApiRequest } from '../hooks/useApi'; import styles from './Settings.module.css'; +// ============================================================================= +// TYPES +// ============================================================================= + +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; + +const _TABS: { key: SettingsTab; label: string }[] = [ + { key: 'profile', label: 'Profil' }, + { key: 'appearance', label: 'Darstellung' }, + { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'privacy', label: 'Datenschutz' }, +]; + // ============================================================================= // PROFILE EDIT MODAL // ============================================================================= @@ -27,39 +40,13 @@ interface ProfileEditModalProps { const ProfileEditModal: React.FC = ({ isOpen, onClose, userData, onSave }) => { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - - // Define editable profile fields + const profileAttributes: AttributeDefinition[] = [ - { - name: 'fullName', - type: 'string', - label: 'Vollständiger Name', - description: 'Ihr vollständiger Name', - required: false, - placeholder: 'Max Mustermann' - }, - { - name: 'email', - type: 'email', - label: 'E-Mail-Adresse', - description: 'Ihre E-Mail-Adresse für Benachrichtigungen', - required: true, - placeholder: 'name@example.com' - }, - { - name: 'language', - type: 'select', - label: 'Sprache', - description: 'Anzeigesprache der Anwendung', - required: true, - options: [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'fr', label: 'Français' } - ] - } + { name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' }, + { name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' }, + { name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] }, ]; - + const handleSubmit = async (formData: any) => { setIsSaving(true); setError(null); @@ -72,9 +59,9 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us setIsSaving(false); } }; - + if (!isOpen) return null; - + return (
e.stopPropagation()}> @@ -84,21 +71,231 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us
{error &&
{error}
} - +
); }; +// ============================================================================= +// VOICE SETTINGS TAB +// ============================================================================= + +interface VoiceMapEntry { language: string; voiceName: string; } + +const VoiceSettingsTab: React.FC = () => { + const { request } = useApiRequest(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [sttLanguage, setSttLanguage] = useState('de-DE'); + const [languages, setLanguages] = useState([]); + const [voiceMap, setVoiceMap] = useState([]); + + const [addLanguage, setAddLanguage] = useState('de-DE'); + const [addVoices, setAddVoices] = useState([]); + const [addVoiceName, setAddVoiceName] = useState(''); + const [loadingVoices, setLoadingVoices] = useState(false); + + const _loadSettings = useCallback(async () => { + setLoading(true); + try { + const [prefsData, languagesData] = await Promise.all([ + request({ url: '/api/local/voice-preferences', method: 'get' }), + request({ url: '/api/local/voice/languages', method: 'get' }), + ]); + + const langList = (languagesData as any)?.languages || []; + setLanguages(langList); + + const prefs = prefsData as any; + setSttLanguage(prefs?.sttLanguage || 'de-DE'); + + const map: Record = prefs?.ttsVoiceMap || {}; + const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ + language: lang, + voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', + })); + setVoiceMap(entries); + } catch (err: any) { + setError(err.message || 'Fehler beim Laden der Voice-Einstellungen'); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _loadSettings(); }, [_loadSettings]); + + const _loadVoicesForLanguage = useCallback(async (lang: string) => { + setLoadingVoices(true); + try { + const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } }); + setAddVoices((result as any)?.voices || []); + setAddVoiceName(''); + } catch { setAddVoices([]); } + finally { setLoadingVoices(false); } + }, [request]); + + useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); + + const _handleAddEntry = useCallback(() => { + if (!addLanguage) return; + const exists = voiceMap.some(e => e.language === addLanguage); + if (exists) { + setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e)); + } else { + setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); + } + setAddVoiceName(''); + }, [addLanguage, addVoiceName, voiceMap]); + + const _handleRemoveEntry = useCallback((lang: string) => { + setVoiceMap(prev => prev.filter(e => e.language !== lang)); + }, []); + + const _handleSave = useCallback(async () => { + setSaving(true); + setError(null); + setSuccess(null); + try { + const mapObj: Record = {}; + voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; }); + await request({ + url: '/api/local/voice-preferences', + method: 'put', + data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj }, + }); + setSuccess('Einstellungen gespeichert'); + setTimeout(() => setSuccess(null), 3000); + await _loadSettings(); + } catch (err: any) { + setError(err.message || 'Fehler beim Speichern'); + } finally { + setSaving(false); + } + }, [request, voiceMap, sttLanguage, _loadSettings]); + + const _handleTestVoice = useCallback(async (lang: string, voice: string) => { + setTesting(lang); + try { + const result: any = await request({ + url: '/api/local/voice/test', + method: 'post', + data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, + }); + if (result?.success && result?.audio) { + const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); + audio.play(); + } + } catch { setError('Stimmtest fehlgeschlagen'); } + finally { setTesting(null); } + }, [request]); + + const _getLanguageName = useCallback((code: string) => { + const found = languages.find((l: any) => (l.code || l) === code); + return found?.name || found?.code || code; + }, [languages]); + + const _defaultLangs = [ + { code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' }, + { code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' }, + { code: 'es-ES', name: 'Espanol' }, + ]; + const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; + + if (loading) return
Einstellungen werden geladen...
; + + return ( + <> + {error &&
{error}
} + {success &&
{success}
} + +
+

STT-Sprache (Spracheingabe)

+
+
+ +

Wird fuer die Sprache-zu-Text-Erkennung verwendet.

+
+
+ +
+
+
+ +
+

TTS-Stimmen (Sprachausgabe)

+

+ Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. +

+ + {voiceMap.length === 0 ? ( +
+ Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. +
+ ) : ( + + + + {voiceMap.map(entry => ( + + + + + + + ))} + +
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} + + + +
+ )} + +
+
+ + +
+
+ + +
+ + +
+
+ + + + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -107,266 +304,135 @@ export const SettingsPage: React.FC = () => { const { currentLanguage, setLanguage } = useLanguage(); const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { updateUser } = useUser(); - - const [theme, setTheme] = useState<'light' | 'dark'>( - () => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' - ); + + const [activeTab, setActiveTab] = useState('profile'); + const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [languageError, setLanguageError] = useState(null); - - // Handle theme change + const handleThemeChange = (newTheme: 'light' | 'dark') => { setTheme(newTheme); localStorage.setItem('theme', newTheme); - - if (newTheme === 'dark') { - document.documentElement.classList.add('dark-theme'); - document.documentElement.classList.remove('light-theme'); - } else { - document.documentElement.classList.add('light-theme'); - document.documentElement.classList.remove('dark-theme'); - } + if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); } + else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); } document.documentElement.setAttribute('data-theme', newTheme); }; - - // Handle language change - save to backend and update cache + const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => { if (!currentUser?.id || !currentUser?.username) return; - setIsSavingLanguage(true); setLanguageError(null); - try { - // 1. Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: currentUser.email, - fullName: currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // 2. Save to backend - await updateUser(currentUser.id, userUpdateData); - - // 3. Update sessionStorage cache + await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ ...cachedUser, language: newLanguage }); - } - - // 4. Update UI language context + if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage }); setLanguage(newLanguage); - - // 5. Dispatch event to notify other components window.dispatchEvent(new CustomEvent('userInfoUpdated')); - - console.log('Language updated successfully to:', newLanguage); - } catch (err: any) { - console.error('Failed to update language:', err); - setLanguageError('Sprache konnte nicht gespeichert werden'); - } finally { - setIsSavingLanguage(false); - } + } catch { setLanguageError('Sprache konnte nicht gespeichert werden'); } + finally { setIsSavingLanguage(false); } }, [currentUser, updateUser, setLanguage]); - - // Handle profile save + const handleProfileSave = useCallback(async (formData: any) => { if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet'); - - // Get the new language (from form or current user) const newLanguage = formData.language || currentUser.language || 'de'; - - // Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: formData.email || currentUser.email, - fullName: formData.fullName || currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // Update user via API - const updatedUser = await updateUser(currentUser.id, userUpdateData); - - // Update sessionStorage cache + const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ - ...cachedUser, - fullName: updatedUser.fullName || cachedUser.fullName, - email: updatedUser.email || cachedUser.email, - language: newLanguage - }); - } - - // Update UI language if changed - if (newLanguage !== currentLanguage) { - setLanguage(newLanguage as 'de' | 'en' | 'fr'); - } - - // Refetch user data - if (refetchUser) { - await refetchUser(); - } - - // Dispatch event to notify other components (e.g., sidebar) + if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage }); + if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr'); + if (refetchUser) await refetchUser(); window.dispatchEvent(new CustomEvent('userInfoUpdated')); - }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); - + return (

Einstellungen

-

Persönliche Einstellungen und Präferenzen

+

Persoenliche Einstellungen und Praeferenzen

- + + +
- {/* Darstellung */} -
-

Darstellung

- -
-
- -

- Wähle zwischen hellem und dunklem Design. -

-
-
-
- - + {activeTab === 'profile' && ( + <> +
+

Konto

+
+
+ +

Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.

+
+
+ +
+
+ {currentUser && ( +
+
Benutzername{currentUser.username}
+
Name{currentUser.fullName || '-'}
+
E-Mail{currentUser.email || '-'}
+
+ )} +
+
+

Ueber

+
+
Version2.0.0
+
Build2026.03.23
+
+
+ + )} + + {activeTab === 'appearance' && ( +
+

Darstellung

+
+

Waehlen Sie zwischen hellem und dunklem Design.

+
+
+ + +
-
- -
-
- -

- Wähle die Anzeigesprache der Anwendung. - {languageError && {languageError}} -

-
-
- - {isSavingLanguage && Speichern...} -
-
-
- - {/* Konto */} -
-

Konto

- -
-
- -

- Ändere deinen Namen und E-Mail-Adresse. -

-
-
- -
-
- - {/* Current user info display */} - {currentUser && ( -
-
- Benutzername - {currentUser.username} -
-
- Name - {currentUser.fullName || '-'} -
-
- E-Mail - {currentUser.email || '-'} +
+

Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && {languageError}}

+
+ + {isSavingLanguage && Speichern...}
- )} -
- - {/* Datenschutz */} -
-

Datenschutz

- -
-
- -

- Data export, portability and account deletion. -

+
+ )} + + {activeTab === 'voice' && } + + {activeTab === 'privacy' && ( +
+

Datenschutz

+
+

Datenexport, Portabilitaet und Kontoloeschung.

+
GDPR oeffnen
-
- - Open GDPR page - -
-
- - - {/* Info */} -
-

Über

- -
-
- Version - 2.0.0 -
-
- Build - 2026.01.20 -
-
-
+ + )} - - {/* Profile Edit Modal */} - setIsProfileModalOpen(false)} - userData={currentUser} - onSave={handleProfileSave} - /> + + setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} /> ); }; diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index 47613db..f7c056a 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -1,47 +1,30 @@ /** * CommCoach Settings View - * - * User profile settings: voice preferences, reminders, email notifications. + * + * Coaching-specific settings: reminders, email notifications, stats. + * Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import api from '../../../api'; import { getProfileApi, updateProfileApi, - getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, type CoachingUserProfile, } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; -async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise { - try { - await api.put('/api/local/voice-preferences', { - sttLanguage: lang, - ttsLanguage: lang, - ttsVoice: voice ?? null, - }); - } catch { - // Silent fallback — shared prefs sync is best-effort - } -} - export const CommcoachSettingsView: React.FC = () => { const { request } = useApiRequest(); const instanceId = useInstanceId(); const [profile, setProfile] = useState(null); - const [languages, setLanguages] = useState([]); - const [voices, setVoices] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [language, setLanguage] = useState('de-DE'); - const [voiceId, setVoiceId] = useState(''); const [reminderEnabled, setReminderEnabled] = useState(false); const [reminderTime, setReminderTime] = useState('09:00'); const [emailEnabled, setEmailEnabled] = useState(true); @@ -51,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => { const loadData = async () => { setLoading(true); try { - const [profileData, languagesData] = await Promise.all([ - getProfileApi(request, instanceId), - getVoiceLanguagesApi(request, instanceId), - ]); + const profileData = await getProfileApi(request, instanceId); setProfile(profileData); - setLanguages(languagesData || []); - if (profileData) { - setLanguage(profileData.preferredLanguage || 'de-DE'); - setVoiceId(profileData.preferredVoice || ''); setReminderEnabled(profileData.dailyReminderEnabled || false); setReminderTime(profileData.dailyReminderTime || '09:00'); setEmailEnabled(profileData.emailSummaryEnabled !== false); } - - const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE'); - setVoices(voicesData || []); } catch (err: any) { setError(err.message || 'Fehler beim Laden'); } finally { @@ -77,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => { loadData(); }, [request, instanceId]); - const handleLanguageChange = useCallback(async (newLang: string) => { - setLanguage(newLang); - if (!instanceId) return; - try { - const voicesData = await getVoiceVoicesApi(request, instanceId, newLang); - setVoices(voicesData || []); - setVoiceId(''); - } catch { /* ignore */ } - }, [request, instanceId]); - const handleSave = useCallback(async () => { if (!instanceId) return; setSaving(true); @@ -94,16 +57,11 @@ export const CommcoachSettingsView: React.FC = () => { setSuccess(null); try { const updated = await updateProfileApi(request, instanceId, { - preferredLanguage: language, - preferredVoice: voiceId || null, dailyReminderEnabled: reminderEnabled, dailyReminderTime: reminderTime, emailSummaryEnabled: emailEnabled, }); setProfile(updated); - - _syncSharedVoicePreferences(language, voiceId || undefined); - setSuccess('Einstellungen gespeichert'); setTimeout(() => setSuccess(null), 3000); } catch (err: any) { @@ -111,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => { } finally { setSaving(false); } - }, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]); - - const handleTestVoice = useCallback(async () => { - if (!instanceId) return; - setTesting(true); - try { - const result = await testVoiceApi(request, instanceId, { - language, - voiceId: voiceId || undefined, - }); - if (result.success && result.audio) { - const audioData = `data:audio/mp3;base64,${result.audio}`; - const audio = new Audio(audioData); - audio.play(); - } - } catch (err: any) { - setError('Sprachtest fehlgeschlagen'); - } finally { - setTesting(false); - } - }, [request, instanceId, language, voiceId]); + }, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]); if (loading) { return
Einstellungen werden geladen...
; @@ -144,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => { {error &&
{error}
} {success &&
{success}
} - {/* Voice Settings */}
-

Sprache und Stimme

- -
- - -
- -
- -
- - -
-
+

Stimme & Sprache

+

+ Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert. +

+ {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}> + Benutzereinstellungen oeffnen (Tab "Stimme & Sprache") +
- {/* Reminder Settings */}

Erinnerungen

-
- {reminderEnabled && (
- setReminderTime(e.target.value)} - /> + setReminderTime(e.target.value)} />
)} -
- {/* Stats */} {profile && (

Statistik

-
- {profile.totalSessions} - Sessions gesamt -
-
- {profile.totalMinutes} - Minuten gesamt -
-
- {profile.streakDays} - Aktueller Streak -
-
- {profile.longestStreak} - Laengster Streak -
+
{profile.totalSessions}Sessions gesamt
+
{profile.totalMinutes}Minuten gesamt
+
{profile.streakDays}Aktueller Streak
+
{profile.longestStreak}Laengster Streak
)} diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.module.css b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css new file mode 100644 index 0000000..a44d272 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css @@ -0,0 +1,16 @@ +.settings { padding: 1rem; max-width: 640px; } +.heading { margin: 0 0 1.5rem; font-size: 1.25rem; font-weight: 600; color: var(--text-primary, #1a1a1a); } +.loading { padding: 2rem; text-align: center; color: #999; } +.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.section { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.5rem; } +.sectionTitle { margin: 0 0 1rem; font-size: 0.95rem; font-weight: 600; } +.field { margin-bottom: 1rem; } +.label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.35rem; } +.input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #d0d0d0); border-radius: 6px; font-size: 0.875rem; background: var(--bg-primary, #fff); color: var(--text-primary, #1a1a1a); } +.input:focus { outline: none; border-color: var(--primary-color, #2563eb); box-shadow: 0 0 0 2px rgba(37,99,235,0.1); } +.removeBtn { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0.5rem; } +.removeBtn:hover { text-decoration: underline; } +.saveBtn { padding: 0.625rem 1.5rem; background: var(--primary-color, #2563eb); color: #fff; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; } +.saveBtn:hover { opacity: 0.9; } +.saveBtn:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx index 901a8fb..8680b3e 100644 --- a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; +import styles from './WorkspaceGeneralSettings.module.css'; interface GeneralSettingsProps { instanceId: string; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index e349e24..9d91a75 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -78,8 +78,9 @@ export const WorkspaceInput: React.FC = ({ const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [treeDropOver, setTreeDropOver] = useState(false); const [voiceActive, setVoiceActive] = useState(false); - const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); + const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [showLangPicker, setShowLangPicker] = useState(false); + const _sttPrefsLoaded = useRef(false); const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); @@ -89,8 +90,13 @@ export const WorkspaceInput: React.FC = ({ const currentInterimRef = useRef(''); useEffect(() => { - localStorage.setItem('workspace_stt_lang', voiceLanguage); - }, [voiceLanguage]); + if (_sttPrefsLoaded.current) return; + _sttPrefsLoaded.current = true; + fetch('/api/local/voice-preferences', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); }) + .catch(() => {}); + }, []); const _extractFileRefs = useCallback( (text: string): string[] => { @@ -679,7 +685,11 @@ export const WorkspaceInput: React.FC = ({ {_STT_LANGUAGES.map(lang => (
{ setVoiceLanguage(lang.code); setShowLangPicker(false); }} + onClick={() => { + setVoiceLanguage(lang.code); + setShowLangPicker(false); + fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); + }} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 13, background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent', diff --git a/src/pages/views/workspace/WorkspaceSettings.module.css b/src/pages/views/workspace/WorkspaceSettings.module.css deleted file mode 100644 index 8138b1e..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.module.css +++ /dev/null @@ -1,173 +0,0 @@ -.settings { - padding: 1rem; - max-width: 600px; -} - -.heading { - font-size: 1.2rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary, #333); -} - -.loading { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.error { - padding: 0.5rem 0.75rem; - background: #fde8e8; - color: var(--color-error, #d32f2f); - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.success { - padding: 0.5rem 0.75rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.section { - margin-bottom: 2rem; -} - -.sectionTitle { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text-primary, #333); -} - -.field { - margin-bottom: 0.75rem; -} - -.label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 0.3rem; - color: var(--text-primary, #333); -} - -.select, .input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color, #ddd); - border-radius: 6px; - font-size: 0.9rem; - background: var(--bg-input, #fff); - color: var(--text-primary, #333); -} - -.voiceRow { - display: flex; - gap: 0.5rem; -} - -.voiceRow .select { - flex: 1; -} - -.testBtn, .addBtn, .removeBtn { - padding: 0.5rem 1rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; -} - -.testBtn:hover:not(:disabled), -.addBtn:hover:not(:disabled) { filter: brightness(1.08); } - -.testBtn:disabled, -.addBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.removeBtn { - background: transparent; - color: var(--color-error, #d32f2f); - padding: 0.3rem 0.6rem; - font-size: 0.8rem; - border: 1px solid var(--color-error, #d32f2f); -} - -.removeBtn:hover { background: #fde8e8; } - -.voiceTable { - width: 100%; - border-collapse: collapse; - margin-top: 0.75rem; -} - -.voiceTable th, -.voiceTable td { - text-align: left; - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - font-size: 0.85rem; -} - -.voiceTable th { - font-weight: 600; - color: var(--text-secondary, #666); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.emptyHint { - color: var(--text-secondary, #999); - font-size: 0.85rem; - font-style: italic; - padding: 0.5rem 0; -} - -.saveBtn { - width: 100%; - padding: 0.6rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; -} - -.saveBtn:hover:not(:disabled) { filter: brightness(1.08); } -.saveBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.backBtn { - background: none; - border: none; - cursor: pointer; - font-size: 0.85rem; - color: var(--primary-color, #1976d2); - padding: 0; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 4px; -} - -.backBtn:hover { text-decoration: underline; } diff --git a/src/pages/views/workspace/WorkspaceSettings.tsx b/src/pages/views/workspace/WorkspaceSettings.tsx deleted file mode 100644 index 11e9c46..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * WorkspaceSettings -- Voice preferences per language. - * - * Allows the user to configure a preferred voice for each TTS language. - * Language detection is automatic; this page lets users override the - * default Google Cloud voice for specific languages. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; - -interface VoiceMapEntry { - language: string; - voiceName: string; -} - -interface WorkspaceSettingsProps { - instanceId: string; -} - -export const WorkspaceSettings: React.FC = ({ instanceId }) => { - const { request } = useApiRequest(); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(null); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const [languages, setLanguages] = useState([]); - const [voiceMap, setVoiceMap] = useState([]); - - const [addLanguage, setAddLanguage] = useState('de-DE'); - const [addVoices, setAddVoices] = useState([]); - const [addVoiceName, setAddVoiceName] = useState(''); - const [loadingVoices, setLoadingVoices] = useState(false); - - const _loadSettings = useCallback(async () => { - if (!instanceId) return; - setLoading(true); - try { - const [settingsData, languagesData] = await Promise.all([ - request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }), - request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); - - const map: Record = (settingsData as any)?.ttsVoiceMap || {}; - const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ - language: lang, - voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', - })); - setVoiceMap(entries); - } catch (err: any) { - setError(err.message || 'Fehler beim Laden der Einstellungen'); - } finally { - setLoading(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadSettings(); }, [_loadSettings]); - - const _loadVoicesForLanguage = useCallback(async (lang: string) => { - if (!instanceId) return; - setLoadingVoices(true); - try { - const result = await request({ - url: `/api/workspace/${instanceId}/voice/voices`, - method: 'get', - params: { language: lang }, - }); - setAddVoices((result as any)?.voices || []); - setAddVoiceName(''); - } catch { - setAddVoices([]); - } finally { - setLoadingVoices(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); - - const _handleAddEntry = useCallback(() => { - if (!addLanguage) return; - const exists = voiceMap.some(e => e.language === addLanguage); - if (exists) { - setVoiceMap(prev => prev.map(e => - e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e - )); - } else { - setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); - } - setAddVoiceName(''); - }, [addLanguage, addVoiceName, voiceMap]); - - const _handleRemoveEntry = useCallback((lang: string) => { - setVoiceMap(prev => prev.filter(e => e.language !== lang)); - }, []); - - const _handleSave = useCallback(async () => { - if (!instanceId) return; - setSaving(true); - setError(null); - setSuccess(null); - try { - const mapObj: Record = {}; - voiceMap.forEach(e => { - mapObj[e.language] = { voiceName: e.voiceName || '' }; - }); - const putResult = await request({ - url: `/api/workspace/${instanceId}/settings/voice`, - method: 'put', - data: { ttsVoiceMap: mapObj }, - }); - if ((putResult as any)?.error) { - setError((putResult as any).error); - return; - } - setSuccess('Einstellungen gespeichert'); - setTimeout(() => setSuccess(null), 3000); - await _loadSettings(); - } catch (err: any) { - setError(err.message || 'Fehler beim Speichern'); - } finally { - setSaving(false); - } - }, [request, instanceId, voiceMap]); - - const _handleTestVoice = useCallback(async (lang: string, voice: string) => { - if (!instanceId) return; - setTesting(lang); - try { - const result: any = await request({ - url: `/api/workspace/${instanceId}/voice/test`, - method: 'post', - data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, - }); - if (result?.success && result?.audio) { - const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); - audio.play(); - } - } catch { - setError('Stimmtest fehlgeschlagen'); - } finally { - setTesting(null); - } - }, [request, instanceId]); - - const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - if (loading) { - return
Einstellungen werden geladen...
; - } - - const _defaultLangs = [ - { code: 'de-DE', name: 'Deutsch' }, - { code: 'en-US', name: 'English (US)' }, - { code: 'fr-FR', name: 'Francais' }, - { code: 'it-IT', name: 'Italiano' }, - { code: 'es-ES', name: 'Espanol' }, - ]; - const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; - - return ( -
-

Stimmeneinstellungen

- - {error &&
{error}
} - {success &&
{success}
} - -
-

Konfigurierte Stimmen pro Sprache

-

- Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. -

- - {voiceMap.length === 0 ? ( -
- Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. -
- ) : ( - - - - - - - - - - - {voiceMap.map(entry => ( - - - - - - - ))} - -
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} - - - -
- )} -
- -
-

Stimme hinzufuegen / aendern

- -
- - -
- -
- -
- - -
-
- - -
- - -
- ); -}; - -export default WorkspaceSettings; diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 6a22317..8f25088 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -1,21 +1,19 @@ /** * WorkspaceSettingsPage -- Tabbed settings for the AI Workspace. * - * First tab: Voice / Language (WorkspaceSettings). - * Additional tabs can be added here as needed. + * Tabs: General settings, Neutralization. + * Voice settings are now in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState } from 'react'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { WorkspaceSettings } from './WorkspaceSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; import NeutralizationPanel from './NeutralizationPanel'; -type SettingsTab = 'general' | 'voice' | 'neutralization'; +type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'voice', label: 'Sprache & Stimme' }, { key: 'neutralization', label: 'Neutralisierung' }, ]; @@ -26,7 +24,7 @@ export const WorkspaceSettingsPage: React.FC = () => { if (!instanceId) { return (
- Keine Workspace-Instanz ausgewählt. + Keine Workspace-Instanz ausgewaehlt.
); } @@ -68,9 +66,6 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'general' && ( )} - {activeTab === 'voice' && ( - - )} {activeTab === 'neutralization' && ( )}