Init v1.0.0
Some checks failed
Build & Push Docker Image / build (push) Failing after 53s

This commit is contained in:
2026-02-24 18:14:16 +01:00
commit 54d6ee553a
49 changed files with 10410 additions and 0 deletions

406
src/pages/Settings.jsx Normal file
View File

@@ -0,0 +1,406 @@
import { useState, useRef } from 'react';
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { themes, getThemeGroups } from '../themes';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Settings() {
const { user, updateUser } = useAuth();
const { theme, setTheme } = useTheme();
const { t, language, setLanguage } = useLanguage();
const [profile, setProfile] = useState({
name: user?.name || '',
email: user?.email || '',
});
const [passwords, setPasswords] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [activeSection, setActiveSection] = useState('profile');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef(null);
const groups = getThemeGroups();
const avatarColors = [
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
'#ec4899', '#f43f5e', '#ef4444', '#f97316',
'#eab308', '#22c55e', '#14b8a6', '#06b6d4',
'#3b82f6', '#2563eb', '#7c3aed', '#64748b',
];
const handleProfileSave = async (e) => {
e.preventDefault();
setSavingProfile(true);
try {
const res = await api.put('/auth/profile', {
name: profile.name,
email: profile.email,
theme,
avatar_color: user?.avatar_color,
});
updateUser(res.data.user);
toast.success(t('settings.profileSaved'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.profileSaveFailed'));
} finally {
setSavingProfile(false);
}
};
const handlePasswordSave = async (e) => {
e.preventDefault();
if (passwords.newPassword !== passwords.confirmPassword) {
toast.error(t('settings.passwordMismatch'));
return;
}
setSavingPassword(true);
try {
await api.put('/auth/password', {
currentPassword: passwords.currentPassword,
newPassword: passwords.newPassword,
});
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
toast.success(t('settings.passwordChanged'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.passwordChangeFailed'));
} finally {
setSavingPassword(false);
}
};
const handleAvatarColor = async (color) => {
try {
const res = await api.put('/auth/profile', { avatar_color: color });
updateUser(res.data.user);
} catch {
// Ignore
}
};
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error(t('settings.avatarInvalidType'));
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error(t('settings.avatarTooLarge'));
return;
}
setUploadingAvatar(true);
try {
const res = await api.post('/auth/avatar', file, {
headers: { 'Content-Type': file.type },
});
updateUser(res.data.user);
toast.success(t('settings.avatarUploaded'));
} catch (err) {
toast.error(err.response?.data?.error || t('settings.avatarUploadFailed'));
} finally {
setUploadingAvatar(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleAvatarRemove = async () => {
try {
const res = await api.delete('/auth/avatar');
updateUser(res.data.user);
toast.success(t('settings.avatarRemoved'));
} catch {
toast.error(t('settings.avatarRemoveFailed'));
}
};
const sections = [
{ id: 'profile', label: t('settings.profile'), icon: User },
{ id: 'password', label: t('settings.password'), icon: Lock },
{ id: 'language', label: t('settings.language'), icon: Globe },
{ id: 'themes', label: t('settings.themes'), icon: Palette },
];
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-th-text">{t('settings.title')}</h1>
<p className="text-sm text-th-text-s mt-1">{t('settings.subtitle')}</p>
</div>
<div className="flex flex-col md:flex-row gap-6">
{/* Section nav */}
<div className="md:w-56 flex-shrink-0">
<nav className="flex md:flex-col gap-1">
{sections.map(s => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeSection === s.id
? 'bg-th-accent text-th-accent-t'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`}
>
<s.icon size={16} />
{s.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="flex-1 max-w-2xl">
{/* Profile section */}
{activeSection === 'profile' && (
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.editProfile')}</h2>
{/* Avatar */}
<div className="mb-6">
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatar')}</label>
<div className="flex items-center gap-4">
<div className="relative group">
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.name?.[0]?.toUpperCase() || '?'}
</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
{uploadingAvatar ? (
<Loader2 size={20} className="text-white animate-spin" />
) : (
<Camera size={20} className="text-white" />
)}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn-ghost text-xs py-1.5 px-3"
>
<Camera size={14} />
{t('settings.uploadImage')}
</button>
{user?.avatar_image && (
<button
type="button"
onClick={handleAvatarRemove}
className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error"
>
<X size={14} />
{t('settings.removeImage')}
</button>
)}
</div>
<p className="text-xs text-th-text-s">{t('settings.avatarHint')}</p>
</div>
</div>
</div>
{/* Avatar Color (fallback) */}
<div className="mb-6">
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label>
<div className="flex flex-wrap gap-2">
{avatarColors.map(color => (
<button
key={color}
onClick={() => handleAvatarColor(color)}
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
}`}
style={{ backgroundColor: color, '--tw-ring-offset-color': 'var(--bg-primary)' }}
/>
))}
</div>
<p className="text-xs text-th-text-s mt-2">{t('settings.avatarColorHint')}</p>
</div>
<form onSubmit={handleProfileSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={profile.name}
onChange={e => setProfile({ ...profile, name: e.target.value })}
className="input-field pl-11"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="email"
value={profile.email}
onChange={e => setProfile({ ...profile, email: e.target.value })}
className="input-field pl-11"
required
/>
</div>
</div>
<button type="submit" disabled={savingProfile} className="btn-primary">
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('common.save')}
</button>
</form>
</div>
)}
{/* Password section */}
{activeSection === 'password' && (
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
<form onSubmit={handlePasswordSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={passwords.currentPassword}
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
className="input-field pl-11"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={passwords.newPassword}
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
className="input-field pl-11"
placeholder={t('auth.minPassword')}
required
minLength={6}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="password"
value={passwords.confirmPassword}
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
className="input-field pl-11"
placeholder={t('auth.repeatPassword')}
required
minLength={6}
/>
</div>
</div>
<button type="submit" disabled={savingPassword} className="btn-primary">
{savingPassword ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('settings.changePassword')}
</button>
</form>
</div>
)}
{/* Language section */}
{activeSection === 'language' && (
<div className="card p-6">
<h2 className="text-lg font-semibold text-th-text mb-2">{t('settings.selectLanguage')}</h2>
<p className="text-sm text-th-text-s mb-6">{t('settings.subtitle')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
].map(lang => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code)}
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
language === lang.code
? 'border-th-accent shadow-md bg-th-accent/5'
: 'border-transparent hover:border-th-border'
}`}
>
<span className="text-2xl">{lang.flag}</span>
<span className="text-sm font-medium text-th-text">{lang.label}</span>
</button>
))}
</div>
</div>
)}
{/* Themes section */}
{activeSection === 'themes' && (
<div className="space-y-6">
{Object.entries(groups).map(([groupName, groupThemes]) => (
<div key={groupName} className="card p-5">
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-4">{groupName}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{groupThemes.map(th => (
<button
key={th.id}
onClick={() => setTheme(th.id)}
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
theme === th.id
? 'border-th-accent shadow-md'
: 'border-transparent hover:border-th-border'
}`}
>
{/* Color preview */}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
</div>
<div className="text-left">
<p className="text-sm font-medium text-th-text">{th.name}</p>
<p className="text-xs text-th-text-s capitalize">{th.type === 'light' ? t('themes.light') : t('themes.dark')}</p>
</div>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}