213 lines
7.0 KiB
TypeScript
213 lines
7.0 KiB
TypeScript
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';
|
|
import {
|
|
updateProfileSchema,
|
|
type UpdateProfileInput,
|
|
type Profile,
|
|
} from '@family-wishlist/shared';
|
|
import { toast } from 'sonner';
|
|
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'],
|
|
queryFn: () => api.get<Profile>('/api/profile'),
|
|
});
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
watch,
|
|
formState: { errors, isSubmitting, isDirty },
|
|
} = useForm<UpdateProfileInput>({
|
|
resolver: zodResolver(updateProfileSchema),
|
|
defaultValues: { slug: '', displayName: '', bio: '', avatarUrl: '' },
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (data) {
|
|
reset({
|
|
slug: data.slug,
|
|
displayName: data.displayName,
|
|
bio: data.bio ?? '',
|
|
avatarUrl: data.avatarUrl ?? '',
|
|
});
|
|
}
|
|
}, [data, reset]);
|
|
|
|
const update = useMutation({
|
|
mutationFn: (values: UpdateProfileInput) =>
|
|
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
|
|
onSuccess: (p) => {
|
|
toast.success(t('profile.saved'));
|
|
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'));
|
|
},
|
|
});
|
|
|
|
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,
|
|
bio: values.bio ? values.bio : null,
|
|
avatarUrl: values.avatarUrl ? values.avatarUrl : null,
|
|
};
|
|
update.mutate(payload);
|
|
});
|
|
|
|
const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
|
|
|
|
return (
|
|
<div className="grid max-w-2xl gap-6">
|
|
<section className="page-section">
|
|
<h1 className="page-section__title">{t('profile.title')}</h1>
|
|
<p className="page-section__text">
|
|
{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">{t('common.loading')}</div>
|
|
) : (
|
|
<form className="profile-form" onSubmit={submit}>
|
|
<section className="profile-form__avatar-panel">
|
|
<span className="profile-form__avatar-preview">
|
|
{avatarPreview ? (
|
|
<img src={avatarPreview} alt="" className="profile-form__avatar-image" />
|
|
) : (
|
|
<Gift className="h-6 w-6" />
|
|
)}
|
|
</span>
|
|
<div className="profile-form__avatar-copy">
|
|
<h2 className="profile-form__avatar-title">{t('profile.avatar')}</h2>
|
|
<p className="profile-form__avatar-hint">{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">{t('profile.slug')}</Label>
|
|
<Input id="slug" {...register('slug')} />
|
|
{errors.slug && (
|
|
<span className="field__error">{translateValidation(t, errors.slug.message)}</span>
|
|
)}
|
|
</div>
|
|
<div className="field">
|
|
<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">{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="profile-form__actions">
|
|
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{t('common.saveChanges')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|