unified data completed implementation
This commit is contained in:
parent
bc091c399c
commit
9a7e3f42d2
10 changed files with 393 additions and 953 deletions
|
|
@ -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<any[]> {
|
||||
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<any[]> {
|
||||
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<CoachingDocument[]> {
|
||||
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<CoachingDocument> {
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|||
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({
|
||||
instanceId,
|
||||
mandateId,
|
||||
featureCode,
|
||||
featureCode: _featureCode,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
|
|
|||
|
|
@ -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<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<ProfileEditModalProps> = ({ isOpen, onClose, us
|
|||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose}>
|
||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -84,21 +71,231 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
|||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
<FormGeneratorForm
|
||||
attributes={profileAttributes}
|
||||
data={userData}
|
||||
mode="edit"
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onClose}
|
||||
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||
|
||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||
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<string, any> = 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<string, any> = {};
|
||||
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 <div style={{ padding: '1rem', color: '#888' }}>Einstellungen werden geladen...</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>STT-Sprache (Spracheingabe)</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Sprache fuer Spracherkennung</label>
|
||||
<p className={styles.settingDescription}>Wird fuer die Sprache-zu-Text-Erkennung verwendet.</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>TTS-Stimmen (Sprachausgabe)</h2>
|
||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||
</p>
|
||||
|
||||
{voiceMap.length === 0 ? (
|
||||
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
||||
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>Sprache</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>Stimme</th><th /><th /></tr></thead>
|
||||
<tbody>
|
||||
{voiceMap.map(entry => (
|
||||
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{_getLanguageName(entry.language)}</td>
|
||||
<td style={{ padding: '0.5rem' }}>{entry.voiceName || 'Standard'}</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }} onClick={() => _handleTestVoice(entry.language, entry.voiceName)} disabled={testing === entry.language}>
|
||||
{testing === entry.language ? '...' : 'Test'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Sprache</label>
|
||||
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Stimme</label>
|
||||
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
|
||||
<option value="">Standard</option>
|
||||
{addVoices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>{v.displayName || v.name || v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>Zuweisen</button>
|
||||
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
|
||||
{testing === addLanguage ? '...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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<SettingsTab>('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<string | null>(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 (
|
||||
<div className={styles.settings}>
|
||||
<header className={styles.header}>
|
||||
<h1>Einstellungen</h1>
|
||||
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p>
|
||||
<p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
|
||||
</header>
|
||||
|
||||
|
||||
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
|
||||
{_TABS.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
|
||||
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
|
||||
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className={styles.content}>
|
||||
{/* Darstellung */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Theme</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Wähle zwischen hellem und dunklem Design.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<div className={styles.themeToggle}>
|
||||
<button
|
||||
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
{activeTab === 'profile' && (
|
||||
<>
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
{currentUser && (
|
||||
<div className={styles.userInfoCard}>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ueber</h2>
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
|
||||
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
|
||||
<div className={styles.settingControl}>
|
||||
<div className={styles.themeToggle}>
|
||||
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
|
||||
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Sprache</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Wähle die Anzeigesprache der Anwendung.
|
||||
{languageError && <span className={styles.errorText}> {languageError}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={currentLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')}
|
||||
disabled={isSavingLanguage}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Konto */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Ändere deinen Namen und E-Mail-Adresse.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={async () => {
|
||||
await refetchUser();
|
||||
setIsProfileModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Profil öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current user info display */}
|
||||
{currentUser && (
|
||||
<div className={styles.userInfoCard}>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Benutzername</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.username}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Name</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>E-Mail</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.email || '-'}</span>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>Anzeigesprache</label><p className={styles.settingDescription}>Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
||||
<div className={styles.settingControl}>
|
||||
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
|
||||
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Datenschutz */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>GDPR / Privacy</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Data export, portability and account deletion.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilitaet und Kontoloeschung.</p></div>
|
||||
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR oeffnen</Link></div>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
|
||||
Open GDPR page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Über</h2>
|
||||
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Version</span>
|
||||
<span className={styles.infoValue}>2.0.0</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Build</span>
|
||||
<span className={styles.infoValue}>2026.01.20</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Profile Edit Modal */}
|
||||
<ProfileEditModal
|
||||
isOpen={isProfileModalOpen}
|
||||
onClose={() => setIsProfileModalOpen(false)}
|
||||
userData={currentUser}
|
||||
onSave={handleProfileSave}
|
||||
/>
|
||||
|
||||
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<CoachingUserProfile | null>(null);
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voices, setVoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(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 <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||
|
|
@ -144,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
{/* Voice Settings */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Sprache</label>
|
||||
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}>
|
||||
{languages.length > 0 ? (
|
||||
languages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>
|
||||
{lang.name || lang.code || lang}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="de-DE">Deutsch</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="fr-FR">Francais</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Stimme</label>
|
||||
<div className={styles.voiceRow}>
|
||||
<select className={styles.select} value={voiceId} onChange={e => setVoiceId(e.target.value)}>
|
||||
<option value="">Standard</option>
|
||||
{voices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>
|
||||
{v.displayName || v.name || v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className={styles.testBtn} onClick={handleTestVoice} disabled={testing}>
|
||||
{testing ? 'Teste...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={styles.sectionTitle}>Stimme & Sprache</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.
|
||||
</p>
|
||||
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||
Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Reminder Settings */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reminderEnabled}
|
||||
onChange={e => setReminderEnabled(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||
Taegliche Coaching-Erinnerung per E-Mail
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{reminderEnabled && (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Uhrzeit</label>
|
||||
<input
|
||||
type="time"
|
||||
className={styles.input}
|
||||
value={reminderTime}
|
||||
onChange={e => setReminderTime(e.target.value)}
|
||||
/>
|
||||
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
onChange={e => setEmailEnabled(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
|
||||
Session-Zusammenfassung per E-Mail senden
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{profile && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Statistik</h3>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.totalSessions}</span>
|
||||
<span className={styles.statLabel}>Sessions gesamt</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.totalMinutes}</span>
|
||||
<span className={styles.statLabel}>Minuten gesamt</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.streakDays}</span>
|
||||
<span className={styles.statLabel}>Aktueller Streak</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.longestStreak}</span>
|
||||
<span className={styles.statLabel}>Laengster Streak</span>
|
||||
</div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>Sessions gesamt</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>Minuten gesamt</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>Aktueller Streak</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>Laengster Streak</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
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<string[]>([]);
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
|
|
@ -89,8 +90,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
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<WorkspaceInputProps> = ({
|
|||
{_STT_LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.code}
|
||||
onClick={() => { 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',
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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<WorkspaceSettingsProps> = ({ instanceId }) => {
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||
|
||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||
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<string, any> = (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<string, any> = {};
|
||||
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 <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.settings}>
|
||||
<h2 className={styles.heading}>Stimmeneinstellungen</h2>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Konfigurierte Stimmen pro Sprache</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: '#888', marginBottom: '0.5rem' }}>
|
||||
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||
</p>
|
||||
|
||||
{voiceMap.length === 0 ? (
|
||||
<div className={styles.emptyHint}>
|
||||
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.voiceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sprache</th>
|
||||
<th>Stimme</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{voiceMap.map(entry => (
|
||||
<tr key={entry.language}>
|
||||
<td>{_getLanguageName(entry.language)}</td>
|
||||
<td>{entry.voiceName || 'Standard'}</td>
|
||||
<td>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
||||
onClick={() => _handleTestVoice(entry.language, entry.voiceName)}
|
||||
disabled={testing === entry.language}
|
||||
>
|
||||
{testing === entry.language ? '...' : 'Test'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className={styles.removeBtn} onClick={() => _handleRemoveEntry(entry.language)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Stimme hinzufuegen / aendern</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Sprache</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={addLanguage}
|
||||
onChange={e => setAddLanguage(e.target.value)}
|
||||
>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>
|
||||
{lang.name || lang.code || lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Stimme</label>
|
||||
<div className={styles.voiceRow}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={addVoiceName}
|
||||
onChange={e => setAddVoiceName(e.target.value)}
|
||||
disabled={loadingVoices}
|
||||
>
|
||||
<option value="">Standard</option>
|
||||
{addVoices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>
|
||||
{v.displayName || v.name || v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={styles.testBtn}
|
||||
onClick={() => _handleTestVoice(addLanguage, addVoiceName)}
|
||||
disabled={testing !== null}
|
||||
>
|
||||
{testing === addLanguage ? '...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.addBtn} onClick={_handleAddEntry}>
|
||||
Stimme zuweisen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className={styles.saveBtn} onClick={_handleSave} disabled={saving}>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSettings;
|
||||
|
|
@ -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 (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
Keine Workspace-Instanz ausgewählt.
|
||||
Keine Workspace-Instanz ausgewaehlt.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,9 +66,6 @@ export const WorkspaceSettingsPage: React.FC = () => {
|
|||
{activeTab === 'general' && (
|
||||
<WorkspaceGeneralSettings instanceId={instanceId} />
|
||||
)}
|
||||
{activeTab === 'voice' && (
|
||||
<WorkspaceSettings instanceId={instanceId} />
|
||||
)}
|
||||
{activeTab === 'neutralization' && (
|
||||
<NeutralizationPanel instanceId={instanceId} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue