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

@@ -5,8 +5,10 @@ import {
useWishes,
} from '@/features/wishes/wishes.hooks';
import { Archive } from 'lucide-react';
import { useI18n } from '@/i18n/i18n';
export function ArchivePage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('archived');
const restore = useRestoreWish();
const remove = useDeleteWish();
@@ -14,19 +16,19 @@ export function ArchivePage() {
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">Archive</h1>
<h1 className="font-display text-3xl">{t('archive.title')}</h1>
<p className="text-sm text-muted">
Wishes you put aside. Only you see this. Restore them to your active list any time.
{t('archive.description')}
</p>
</section>
{isLoading && <div className="text-muted">Loading...</div>}
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
<Archive className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">Archive is empty</h2>
<p className="text-sm text-muted">Archived wishes will show up here.</p>
<h2 className="text-xl font-semibold">{t('archive.emptyTitle')}</h2>
<p className="text-sm text-muted">{t('archive.emptyText')}</p>
</div>
)}

View File

@@ -5,8 +5,10 @@ import {
useDuplicateWish,
useWishes,
} from '@/features/wishes/wishes.hooks';
import { useI18n } from '@/i18n/i18n';
export function CompletedPage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('completed');
const duplicate = useDuplicateWish();
const remove = useDeleteWish();
@@ -14,20 +16,20 @@ export function CompletedPage() {
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">Fulfilled</h1>
<h1 className="font-display text-3xl">{t('completed.title')}</h1>
<p className="text-sm text-muted">
Wishes you've received. You can create a new wish based on any of them.
{t('completed.description')}
</p>
</section>
{isLoading && <div className="text-muted">Loading...</div>}
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
<CheckCircle2 className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">Nothing fulfilled yet</h2>
<h2 className="text-xl font-semibold">{t('completed.emptyTitle')}</h2>
<p className="text-sm text-muted">
When a wish comes true, mark it as fulfilled and it lands here.
{t('completed.emptyText')}
</p>
</div>
)}

View File

@@ -12,8 +12,10 @@ import {
} from '@/features/wishes/wishes.hooks';
import { useAuthStore } from '@/features/auth/authStore';
import { Link } from 'react-router-dom';
import { useI18n } from '@/i18n/i18n';
export function DashboardPage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('active');
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<Wish | null>(null);
@@ -26,9 +28,9 @@ export function DashboardPage() {
<div className="grid gap-6">
<section className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-display text-3xl">Your wishlist</h1>
<h1 className="font-display text-3xl">{t('dashboard.title')}</h1>
<p className="text-sm text-muted">
Add things you dream of. Share your public page at{' '}
{t('dashboard.description')}
{user && (
<Link
to={`/u/${user.slug}`}
@@ -42,7 +44,7 @@ export function DashboardPage() {
</div>
<Button size="lg" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
Add wish
{t('dashboard.addWish')}
</Button>
</section>
@@ -62,14 +64,14 @@ export function DashboardPage() {
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
<div>
<h2 className="text-xl font-semibold">No wishes yet</h2>
<h2 className="text-xl font-semibold">{t('dashboard.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">
Start by adding something you'd love to receive.
{t('dashboard.emptyText')}
</p>
</div>
<Button onClick={() => setCreating(true)}>
<Sparkles className="h-4 w-4" />
Add your first wish
{t('dashboard.addFirstWish')}
</Button>
</div>
)}

View File

@@ -11,8 +11,11 @@ import { Label } from '@/components/ui/Label';
import { useAuthStore } from '@/features/auth/authStore';
import { ApiError } from '@/lib/api';
import { Footer } from '@/components/Layout/Footer';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { translateValidation, useI18n } from '@/i18n/i18n';
export function LoginPage() {
const { t } = useI18n();
const { user, login } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
@@ -39,29 +42,32 @@ export function LoginPage() {
navigate(from, { replace: true });
} catch (err) {
if (err instanceof ApiError) toast.error(err.message);
else toast.error('Login failed');
else toast.error(t('login.failed'));
}
});
return (
<div className="flex min-h-screen flex-col">
<div className="container-page flex justify-end pt-6">
<LanguageSwitcher />
</div>
<div className="container-page flex flex-1 items-center justify-center py-12">
<div className="w-full max-w-md animate-fade-in-up">
<div className="mb-6 flex items-center justify-center gap-2">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
<Gift className="h-5 w-5" />
</span>
<h1 className="font-display text-3xl">Family Wishlist</h1>
<h1 className="font-display text-3xl">{t('app.name')}</h1>
</div>
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8">
<h2 className="mb-1 text-xl font-semibold">Welcome back</h2>
<h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2>
<p className="mb-6 text-sm text-muted">
Sign in to manage your wishlist. Credentials are set up via the server environment.
{t('login.description')}
</p>
<form className="grid gap-4" onSubmit={submit}>
<div className="field">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t('login.username')}</Label>
<Input
id="username"
autoComplete="username"
@@ -69,11 +75,13 @@ export function LoginPage() {
{...register('username')}
/>
{errors.username && (
<span className="field__error">{errors.username.message}</span>
<span className="field__error">
{translateValidation(t, errors.username.message)}
</span>
)}
</div>
<div className="field">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('login.password')}</Label>
<Input
id="password"
type="password"
@@ -81,12 +89,14 @@ export function LoginPage() {
{...register('password')}
/>
{errors.password && (
<span className="field__error">{errors.password.message}</span>
<span className="field__error">
{translateValidation(t, errors.password.message)}
</span>
)}
</div>
<Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Sign in
{t('login.submit')}
</Button>
</form>
</div>

View File

@@ -1,14 +1,16 @@
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { useI18n } from '@/i18n/i18n';
export function NotFoundPage() {
const { t } = useI18n();
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card">
<h1 className="font-display text-4xl">404</h1>
<p className="mt-2 text-muted">We couldn't find that page.</p>
<p className="mt-2 text-muted">{t('notFound.text')}</p>
<Link to="/" className="mt-4 inline-block">
<Button variant="secondary">Back to home</Button>
<Button variant="secondary">{t('common.backHome')}</Button>
</Link>
</div>
</div>

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>

View File

@@ -9,8 +9,11 @@ import { api } from '@/lib/api';
import { WishCard } from '@/components/WishCard/WishCard';
import { Footer } from '@/components/Layout/Footer';
import { Gift } from 'lucide-react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { useI18n } from '@/i18n/i18n';
export function PublicProfilePage() {
const { t } = useI18n();
const { slug = '' } = useParams<{ slug: string }>();
const queryClient = useQueryClient();
@@ -53,14 +56,17 @@ export function PublicProfilePage() {
return (
<div className="flex min-h-screen flex-col">
<div className="container-page flex justify-end pt-6">
<LanguageSwitcher />
</div>
<main className="container-page flex-1 py-10">
{profile.isLoading && <div className="text-muted">Loading...</div>}
{profile.isLoading && <div className="text-muted">{t('common.loading')}</div>}
{profile.isError && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
<h1 className="font-display text-2xl">Profile not found</h1>
<h1 className="font-display text-2xl">{t('public.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-muted">
Check the link and try again. Slugs are case-sensitive.
{t('public.notFoundText')}
</p>
</div>
)}
@@ -79,18 +85,20 @@ export function PublicProfilePage() {
<Gift className="h-6 w-6" />
)}
</span>
<h1 className="font-display text-4xl">{profile.data.displayName}'s wishlist</h1>
<h1 className="font-display text-4xl">
{t('public.wishlistTitle', { name: profile.data.displayName })}
</h1>
{profile.data.bio && (
<p className="max-w-xl text-muted">{profile.data.bio}</p>
)}
</section>
{wishes.isLoading && <div className="text-muted">Loading wishes...</div>}
{wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
{wishes.data && wishes.data.length === 0 && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
<h2 className="text-xl font-semibold">No wishes yet</h2>
<p className="mt-1 text-sm text-muted">Check back later!</p>
<h2 className="text-xl font-semibold">{t('public.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">{t('public.emptyText')}</p>
</div>
)}

View File

@@ -3,27 +3,31 @@ import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
import { WishCard } from '@/components/WishCard/WishCard';
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
import { daysLeftUntil } from '@/lib/format';
import { useI18n } from '@/i18n/i18n';
export function TrashPage() {
const { dayCount, t } = useI18n();
const { data, isLoading } = useWishes('deleted');
const restore = useRestoreWish();
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">Trash</h1>
<h1 className="font-display text-3xl">{t('trash.title')}</h1>
<p className="text-sm text-muted">
Deleted wishes are kept for {TRASH_RETENTION_DAYS} days, then permanently removed.
{t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p>
</section>
{isLoading && <div className="text-muted">Loading...</div>}
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
<Trash2 className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">Trash is empty</h2>
<p className="text-sm text-muted">Deleted wishes will appear here for 30 days.</p>
<h2 className="text-xl font-semibold">{t('trash.emptyTitle')}</h2>
<p className="text-sm text-muted">
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p>
</div>
)}
@@ -41,7 +45,7 @@ export function TrashPage() {
onRestore={() => restore.mutate(wish.id)}
footer={
<p className="mt-2 text-xs font-medium text-warning">
Auto-removes in {left} day{left === 1 ? '' : 's'}
{t('trash.autoRemove', { days: dayCount(left) })}
</p>
}
/>