590 lines
28 KiB
TypeScript
590 lines
28 KiB
TypeScript
/**
|
|
* Settings Page — User-level settings with tabs.
|
|
* Route: /settings
|
|
*/
|
|
|
|
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' | 'neutralization' | 'privacy';
|
|
|
|
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
|
return [
|
|
{ key: 'profile', label: t('settings.tabProfil') },
|
|
{ key: 'appearance', label: t('settings.tabDarstellung') },
|
|
{ key: 'voice', label: t('settings.tabStimmeSprache') },
|
|
{ key: 'neutralization', label: t('settings.tabNeutralisierung') },
|
|
{ key: 'privacy', label: t('settings.tabDatenschutz') },
|
|
];
|
|
}
|
|
|
|
// =============================================================================
|
|
// PROFILE EDIT MODAL
|
|
// =============================================================================
|
|
|
|
interface ProfileEditModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
userData: any;
|
|
onSave: (data: any) => Promise<void>;
|
|
}
|
|
|
|
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { t, availableLanguages } = useLanguage();
|
|
|
|
const languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }));
|
|
|
|
const profileAttributes: AttributeDefinition[] = [
|
|
{ name: 'fullName', type: 'string', label: t('settings.vollstaendigerName'), description: t('settings.ihrVollstaendigerName'), required: false, placeholder: t('settings.placeholderName') },
|
|
{ name: 'email', type: 'email', label: t('settings.emailAdresse'), description: t('settings.emailBeschreibung'), required: true, placeholder: t('settings.placeholderEmail') },
|
|
{ name: 'language', type: 'select', label: t('settings.sprache'), description: t('settings.anzeigespracheDerAnwendung'), required: true, options: languageOptions },
|
|
];
|
|
|
|
const handleSubmit = async (formData: any) => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
await onSave(formData);
|
|
onClose();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Fehler beim Speichern des Profils');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className={styles.modalOverlay} onClick={onClose}>
|
|
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
|
<div className={styles.modalHeader}>
|
|
<h2>{t('settings.profilBearbeiten')}</h2>
|
|
<button className={styles.closeButton} onClick={onClose}>×</button>
|
|
</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 ? t('settings.speichern') : t('settings.speichern')} cancelButtonText={t('settings.abbrechen')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// VOICE SETTINGS TAB
|
|
// =============================================================================
|
|
|
|
interface VoiceMapEntry { language: string; voiceName: string; }
|
|
|
|
const VoiceSettingsTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
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/voice/preferences', method: 'get' }),
|
|
request({ url: '/api/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/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/voice/preferences',
|
|
method: 'put',
|
|
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
|
|
});
|
|
setSuccess(t('settings.einstellungenGespeichert'));
|
|
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/voice/test',
|
|
method: 'post',
|
|
data: { language: lang, voiceId: voice || undefined },
|
|
});
|
|
if (result?.success && result?.audio) {
|
|
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
|
audio.play();
|
|
}
|
|
} catch { setError(t('settings.stimmtestFehlgeschlagen')); }
|
|
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' }}>{t('settings.einstellungenWerdenGeladen')}</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}>{t('settings.sttspracheSpracheingabe')}</h2>
|
|
<div className={styles.settingRow}>
|
|
<div className={styles.settingInfo}>
|
|
<label className={styles.settingLabel}>{t('settings.spracheFuerSpracherkennung')}</label>
|
|
<p className={styles.settingDescription}>{t('settings.wirdFuerDieSprachezutexterkennungVerwendet')}</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}>{t('settings.ttsstimmenSprachausgabe')}</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' }}>{t('settings.sprache')}</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('settings.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)}>{t('settings.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' }}>{t('settings.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' }}>{t('settings.stimme')}</label>
|
|
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
|
|
<option value="">{t('settings.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' }}>{t('settings.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 ? t('settings.speichern') : t('settings.einstellungenSpeichern')}
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// NEUTRALIZATION MAPPINGS TAB
|
|
// =============================================================================
|
|
|
|
interface NeutralizationMapping {
|
|
id: string;
|
|
originalText: string;
|
|
patternType: string;
|
|
fileId?: string;
|
|
featureInstanceId?: string;
|
|
}
|
|
|
|
const NeutralizationMappingsTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const _load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' });
|
|
const items = (result?.mappings || []).map((m: any) => ({
|
|
id: m.id,
|
|
originalText: m.originalText || '',
|
|
patternType: m.patternType || '',
|
|
fileId: m.fileId,
|
|
featureInstanceId: m.featureInstanceId,
|
|
}));
|
|
setMappings(items);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Fehler beim Laden');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [request]);
|
|
|
|
useEffect(() => { _load(); }, [_load]);
|
|
|
|
const _handleDelete = useCallback(async (id: string) => {
|
|
try {
|
|
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
|
|
setMappings(prev => prev.filter(m => m.id !== id));
|
|
} catch (err: any) {
|
|
setError(err.message || 'Fehler beim Loeschen');
|
|
}
|
|
}, [request]);
|
|
|
|
const _maskText = (text: string) => {
|
|
if (text.length <= 4) return '****';
|
|
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
|
|
};
|
|
|
|
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('settings.mappingsWerdenGeladen')}</div>;
|
|
|
|
return (
|
|
<>
|
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
|
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('settings.platzhaltermappingsLokal')}</h2>
|
|
<div
|
|
style={{
|
|
marginBottom: '1rem',
|
|
padding: '0.75rem 1rem',
|
|
background: 'var(--surface-color, #eff6ff)',
|
|
border: '1px solid var(--border-color, #bfdbfe)',
|
|
borderRadius: 8,
|
|
fontSize: '0.85rem',
|
|
lineHeight: 1.5,
|
|
color: 'var(--text-primary, #1e3a5f)',
|
|
}}
|
|
>
|
|
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
|
|
<strong>{t('settings.mandantAiworkspaceinstanzEinstellungenTabNeutralisierung')}</strong> (nicht auf dieser
|
|
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
|
|
</div>
|
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
|
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
|
|
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
|
|
den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.
|
|
</p>
|
|
|
|
{mappings.length === 0 ? (
|
|
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
|
Keine Neutralisierungs-Mappings vorhanden.
|
|
</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' }}>Platzhalter-ID</th>
|
|
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
|
|
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mappings.map(m => (
|
|
<tr key={m.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
|
<td style={{ padding: '0.5rem', fontFamily: 'monospace', fontSize: '0.75rem' }}>{m.id.slice(0, 12)}...</td>
|
|
<td style={{ padding: '0.5rem' }}>{_maskText(m.originalText)}</td>
|
|
<td style={{ padding: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: '#f3f4f6' }}>
|
|
{m.patternType}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '0.5rem' }}>
|
|
<button
|
|
className={styles.button}
|
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
|
|
onClick={() => _handleDelete(m.id)}
|
|
>
|
|
Loeschen
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// SETTINGS PAGE
|
|
// =============================================================================
|
|
|
|
export const SettingsPage: React.FC = () => {
|
|
const { t, currentLanguage, setLanguage, availableLanguages, refreshAvailableLanguages } = useLanguage();
|
|
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
|
const { updateUser } = useUser();
|
|
|
|
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);
|
|
|
|
useEffect(() => {
|
|
if (availableLanguages.length === 0) {
|
|
refreshAvailableLanguages();
|
|
}
|
|
}, [availableLanguages.length, refreshAvailableLanguages]);
|
|
|
|
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'); }
|
|
document.documentElement.setAttribute('data-theme', newTheme);
|
|
};
|
|
|
|
const handleLanguageChange = useCallback(async (newLanguage: string) => {
|
|
if (!currentUser?.id || !currentUser?.username) return;
|
|
setIsSavingLanguage(true);
|
|
setLanguageError(null);
|
|
try {
|
|
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 });
|
|
setLanguage(newLanguage);
|
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
|
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
|
|
finally { setIsSavingLanguage(false); }
|
|
}, [currentUser, updateUser, setLanguage]);
|
|
|
|
const handleProfileSave = useCallback(async (formData: any) => {
|
|
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
|
const newLanguage = formData.language || currentUser.language || 'de';
|
|
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 });
|
|
if (newLanguage !== currentLanguage) setLanguage(newLanguage);
|
|
if (refetchUser) await refetchUser();
|
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
|
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
|
|
|
return (
|
|
<div className={styles.settings}>
|
|
<header className={styles.header}>
|
|
<h1>{t('settings.einstellungen')}</h1>
|
|
<p className={styles.subtitle}>{t('settings.persoenlicheEinstellungenUndPraeferenzen')}</p>
|
|
</header>
|
|
|
|
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
|
|
{_getTabs(t).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}>
|
|
{activeTab === 'profile' && (
|
|
<>
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('settings.konto')}</h2>
|
|
<div className={styles.settingRow}>
|
|
<div className={styles.settingInfo}>
|
|
<label className={styles.settingLabel}>{t('settings.profilBearbeiten')}</label>
|
|
<p className={styles.settingDescription}>{t('settings.aendernSieIhrenNamenUnd')}</p>
|
|
</div>
|
|
<div className={styles.settingControl}>
|
|
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>{t('settings.profilOeffnen')}</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}>{t('settings.darstellung')}</h2>
|
|
<div className={styles.settingRow}>
|
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>{t('settings.waehlenSieZwischenHellemUnd')}</p></div>
|
|
<div className={styles.settingControl}>
|
|
<div className={styles.themeToggle}>
|
|
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('settings.themeHell')}</button>
|
|
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>{t('settings.themeDunkel')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.settingRow}>
|
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('settings.anzeigesprache')}</label><p className={styles.settingDescription}>{t('settings.spracheBeschreibung')}{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)} disabled={isSavingLanguage}>
|
|
{availableLanguages.map((l) => (
|
|
<option key={l.code} value={l.code}>
|
|
{l.label || l.code}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{isSavingLanguage && <span className={styles.savingIndicator}>{t('settings.speichern')}</span>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{activeTab === 'voice' && <VoiceSettingsTab />}
|
|
|
|
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
|
|
|
{activeTab === 'privacy' && (
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('settings.datenschutz')}</h2>
|
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
|
{t('settings.datenschutzBeschreibung')}
|
|
</p>
|
|
<div className={styles.settingRow}>
|
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('settings.gdprPrivacy')}</label><p className={styles.settingDescription}>{t('settings.datenexportPortabilitaetUndKontoloeschung')}</p></div>
|
|
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('settings.gdprOeffnen')}</Link></div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SettingsPage;
|