ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the model defaults. Language names now come from Intl.DisplayNames rather than the catalog. The runtime already knows "German" in English and "alemao" in Portuguese, so 28 names would have been 56 hand-written entries that Intl gets right for free -- including for locales not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays hardcoded: which languages you may claim to speak is a product choice. ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale -- the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now. Scanner: an Authorization header value like "Bearer <token>" is not a sentence. Excluded by auth-scheme prefix; verified the delta is exactly the four such credentials across both remaining areas.
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { Camera } from 'lucide-react';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useForm } from 'hooks/useForm';
|
|
import { useAuth } from 'hooks/useAuth';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
type ProfileFormState = {
|
|
name?: string;
|
|
};
|
|
|
|
const MAX_AVATAR_SIZE = 384_000; // ~384KB to stay under 512KB varchar after base64 overhead
|
|
|
|
const readFileAsBase64 = (file: File): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
export const UserData = () => {
|
|
const { t } = useTranslation();
|
|
const { user, updateUser } = useAuth();
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const profileForm = useForm<ProfileFormState>({ name: user?.name ?? '' });
|
|
|
|
const handleAvatarChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = ev.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error(t('settings.profile.userData.selectImage'));
|
|
return;
|
|
}
|
|
|
|
if (file.size > MAX_AVATAR_SIZE) {
|
|
toast.error(t('settings.profile.userData.imageTooLarge'));
|
|
return;
|
|
}
|
|
|
|
const base64 = await readFileAsBase64(file);
|
|
setAvatarPreview(base64);
|
|
};
|
|
|
|
const handleSubmit = async (ev: React.FormEvent) => {
|
|
ev.preventDefault();
|
|
if (isUpdating) return;
|
|
|
|
setIsUpdating(true);
|
|
try {
|
|
await updateUser({
|
|
name: profileForm.state.name ?? '',
|
|
avatar: avatarPreview ?? user?.avatar ?? '',
|
|
});
|
|
toast.success(t('settings.profile.userData.updated'));
|
|
setAvatarPreview(null);
|
|
} catch (ex) {
|
|
const error = ex as { message?: string };
|
|
toast.error(error.message || t('settings.profile.userData.updateFailed'));
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
const displayAvatar = avatarPreview ?? user?.avatar ?? undefined;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-center mb-6">
|
|
<button
|
|
type="button"
|
|
className="relative group cursor-pointer rounded-full"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Avatar className="h-20 w-20 rounded-full">
|
|
<AvatarImage src={displayAvatar} />
|
|
<AvatarFallback className="bg-duck-teal text-duck-yellow text-2xl font-bold rounded-full">
|
|
{user?.name?.charAt(0).toUpperCase() ?? '?'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<Camera className="h-6 w-6 text-white" />
|
|
</div>
|
|
</button>
|
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
|
</div>
|
|
|
|
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="email"
|
|
value={user?.email ?? ''}
|
|
disabled
|
|
/>
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="text"
|
|
value={user?.username ?? ''}
|
|
disabled
|
|
/>
|
|
</Label>
|
|
|
|
<Label className="grid gap-2">
|
|
<span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
type="text"
|
|
name="name"
|
|
placeholder={t('settings.profile.userData.namePlaceholder')}
|
|
autoComplete="name"
|
|
/>
|
|
</Label>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isUpdating}
|
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
>
|
|
{isUpdating ? t('common.saving') : t('common.save')}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|