Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/UserData.tsx
T
pastilhas ad8ed88519 profile settings keyed (425 -> 352)
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.
2026-08-23 04:57:48 +00:00

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>
);
};