feat: add i18n and avatar upload

This commit is contained in:
Vaka.pro
2026-04-26 22:16:59 +03:00
parent db41d4a246
commit 1b23097b18
22 changed files with 750 additions and 145 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -8,17 +8,23 @@ import {
type Profile,
} from '@family-wishlist/shared';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { Gift, Loader2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Textarea } from '@/components/ui/Textarea';
import { api, ApiError } from '@/lib/api';
import { useAuthStore } from '@/features/auth/authStore';
import { translateValidation, useI18n } from '@/i18n/i18n';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
export function ProfileSettingsPage() {
const { t } = useI18n();
const refresh = useAuthStore((s) => s.refresh);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading } = useQuery({
queryKey: ['profile'],
@@ -29,6 +35,7 @@ export function ProfileSettingsPage() {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting, isDirty },
} = useForm<UpdateProfileInput>({
resolver: zodResolver(updateProfileSchema),
@@ -50,7 +57,7 @@ export function ProfileSettingsPage() {
mutationFn: (values: UpdateProfileInput) =>
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
onSuccess: (p) => {
toast.success('Profile saved');
toast.success(t('profile.saved'));
void queryClient.invalidateQueries({ queryKey: ['profile'] });
void refresh();
reset({
@@ -62,10 +69,46 @@ export function ProfileSettingsPage() {
},
onError: (err) => {
if (err instanceof ApiError) toast.error(err.message);
else toast.error('Save failed');
else toast.error(t('profile.saveFailed'));
},
});
const uploadAvatar = useMutation({
mutationFn: (file: File) => {
const form = new FormData();
form.append('file', file);
return api.upload<Profile>('/api/profile/avatar', form);
},
onSuccess: (p) => {
toast.success(t('profile.avatarUploaded'));
void queryClient.invalidateQueries({ queryKey: ['profile'] });
void refresh();
reset({
slug: p.slug,
displayName: p.displayName,
bio: p.bio ?? '',
avatarUrl: p.avatarUrl ?? '',
});
},
onError: (err) => {
if (err instanceof ApiError) toast.error(err.message);
else toast.error(t('profile.saveFailed'));
},
});
function handleAvatarFile(file: File | undefined): void {
if (!file) return;
if (!AVATAR_MIME_TYPES.has(file.type)) {
toast.error(t('profile.avatarUnsupported'));
return;
}
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t('profile.avatarTooLarge'));
return;
}
uploadAvatar.mutate(file);
}
const submit = handleSubmit((values) => {
const payload: UpdateProfileInput = {
...values,
@@ -75,43 +118,91 @@ export function ProfileSettingsPage() {
update.mutate(payload);
});
const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
return (
<div className="grid max-w-2xl gap-6">
<section>
<h1 className="font-display text-3xl">Profile</h1>
<h1 className="font-display text-3xl">{t('profile.title')}</h1>
<p className="text-sm text-muted">
Your public page lives at <code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
{t('profile.publicPage')}
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
</p>
</section>
{isLoading ? (
<div className="text-muted">Loading...</div>
<div className="text-muted">{t('common.loading')}</div>
) : (
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}>
<section className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4">
<span className="inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground">
{avatarPreview ? (
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" />
) : (
<Gift className="h-6 w-6" />
)}
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">{t('profile.avatar')}</h2>
<p className="text-xs text-muted">{t('profile.avatarHint')}</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={(e) => {
handleAvatarFile(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
>
{uploadAvatar.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('profile.uploadAvatar')}
</Button>
</section>
<div className="field">
<Label htmlFor="slug">Slug (public URL)</Label>
<Label htmlFor="slug">{t('profile.slug')}</Label>
<Input id="slug" {...register('slug')} />
{errors.slug && <span className="field__error">{errors.slug.message}</span>}
</div>
<div className="field">
<Label htmlFor="displayName">Display name</Label>
<Input id="displayName" {...register('displayName')} />
{errors.displayName && (
<span className="field__error">{errors.displayName.message}</span>
{errors.slug && (
<span className="field__error">{translateValidation(t, errors.slug.message)}</span>
)}
</div>
<div className="field">
<Label htmlFor="bio">Bio</Label>
<Label htmlFor="displayName">{t('profile.displayName')}</Label>
<Input id="displayName" {...register('displayName')} />
{errors.displayName && (
<span className="field__error">
{translateValidation(t, errors.displayName.message)}
</span>
)}
</div>
<div className="field">
<Label htmlFor="bio">{t('profile.bio')}</Label>
<Textarea id="bio" rows={3} {...register('bio')} />
</div>
<div className="field">
<Label htmlFor="avatarUrl">Avatar URL</Label>
<Input id="avatarUrl" type="url" {...register('avatarUrl')} />
<Label htmlFor="avatarUrl">{t('profile.avatarUrl')}</Label>
<Input id="avatarUrl" inputMode="url" {...register('avatarUrl')} />
{errors.avatarUrl && (
<span className="field__error">
{translateValidation(t, errors.avatarUrl.message)}
</span>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
{t('common.saveChanges')}
</Button>
</div>
</form>