From 1b23097b18505c5f50273839e3120e786add13db Mon Sep 17 00:00:00 2001 From: "Vaka.pro" Date: Sun, 26 Apr 2026 22:16:59 +0300 Subject: [PATCH] feat: add i18n and avatar upload --- .../src/modules/images/storage.service.ts | 14 + .../src/modules/profile/profile.routes.ts | 26 +- apps/frontend/src/App.tsx | 15 +- .../src/components/LanguageSwitcher.tsx | 41 +++ .../frontend/src/components/Layout/Footer.tsx | 8 +- .../frontend/src/components/Layout/Header.tsx | 34 +- .../src/components/Layout/ProtectedRoute.tsx | 6 +- .../src/components/WishBadges/WishBadges.tsx | 10 +- .../src/components/WishCard/WishCard.tsx | 26 +- .../src/components/WishForm/WishForm.tsx | 59 +-- .../src/features/wishes/wishes.hooks.ts | 53 +-- apps/frontend/src/i18n/i18n.tsx | 335 ++++++++++++++++++ apps/frontend/src/lib/format.ts | 14 +- apps/frontend/src/pages/ArchivePage.tsx | 12 +- apps/frontend/src/pages/CompletedPage.tsx | 12 +- apps/frontend/src/pages/DashboardPage.tsx | 14 +- apps/frontend/src/pages/LoginPage.tsx | 28 +- apps/frontend/src/pages/NotFoundPage.tsx | 6 +- .../src/pages/ProfileSettingsPage.tsx | 129 ++++++- apps/frontend/src/pages/PublicProfilePage.tsx | 22 +- apps/frontend/src/pages/TrashPage.tsx | 16 +- packages/shared/src/profile.schema.ts | 15 +- 22 files changed, 750 insertions(+), 145 deletions(-) create mode 100644 apps/frontend/src/components/LanguageSwitcher.tsx create mode 100644 apps/frontend/src/i18n/i18n.tsx diff --git a/apps/backend/src/modules/images/storage.service.ts b/apps/backend/src/modules/images/storage.service.ts index ca8977c..fb65a4e 100644 --- a/apps/backend/src/modules/images/storage.service.ts +++ b/apps/backend/src/modules/images/storage.service.ts @@ -25,6 +25,20 @@ export async function saveUploadedImage( return { imageUrl: relative }; } +export async function saveUploadedAvatar( + userId: string, + mime: string, + buffer: Buffer, +): Promise<{ imageUrl: string }> { + const ext = MIME_TO_EXT[mime]; + if (!ext) throw new ValidationError('Unsupported image type'); + const filename = `${userId}-${nanoid(8)}.${ext}`; + const relative = `/uploads/avatar/${filename}`; + const absPath = resolve(env.UPLOADS_DIR, 'avatar', filename); + await writeFile(absPath, buffer); + return { imageUrl: relative }; +} + export async function deleteLocalImageIfAny(imageUrl: string | null): Promise { if (!imageUrl) return; if (!imageUrl.startsWith('/uploads/')) return; diff --git a/apps/backend/src/modules/profile/profile.routes.ts b/apps/backend/src/modules/profile/profile.routes.ts index 90c9187..9ed433e 100644 --- a/apps/backend/src/modules/profile/profile.routes.ts +++ b/apps/backend/src/modules/profile/profile.routes.ts @@ -1,7 +1,10 @@ import type { FastifyInstance } from 'fastify'; import { updateProfileSchema } from '@family-wishlist/shared'; -import { ConflictError, NotFoundError } from '../../utils/errors.js'; +import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors.js'; import { Prisma } from '@prisma/client'; +import { deleteLocalImageIfAny, saveUploadedAvatar } from '../images/storage.service.js'; + +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; export default async function profileRoutes(app: FastifyInstance) { app.addHook('preHandler', app.authenticate); @@ -27,4 +30,25 @@ export default async function profileRoutes(app: FastifyInstance) { throw err; } }); + + app.post('/avatar', async (request) => { + const current = await app.prisma.user.findUnique({ where: { id: request.user.id } }); + if (!current) throw new NotFoundError('Profile'); + + const data = await request.file(); + if (!data) throw new ValidationError('No file uploaded'); + + const buffer = await data.toBuffer(); + if (buffer.byteLength > MAX_AVATAR_BYTES) { + throw new ValidationError('Avatar must be 2 MB or less'); + } + + const { imageUrl } = await saveUploadedAvatar(request.user.id, data.mimetype, buffer); + await deleteLocalImageIfAny(current.avatarUrl); + + return app.prisma.user.update({ + where: { id: request.user.id }, + data: { avatarUrl: imageUrl }, + }); + }); } diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3558194..6bc5d4f 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { router } from './routes'; import { useAuthStore } from './features/auth/authStore'; +import { I18nProvider } from './i18n/i18n'; const queryClient = new QueryClient({ defaultOptions: { @@ -24,11 +25,13 @@ function AuthBoot({ children }: { children: React.ReactNode }) { export function App() { return ( - - - - - - + + + + + + + + ); } diff --git a/apps/frontend/src/components/LanguageSwitcher.tsx b/apps/frontend/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..534e669 --- /dev/null +++ b/apps/frontend/src/components/LanguageSwitcher.tsx @@ -0,0 +1,41 @@ +import { Languages } from 'lucide-react'; +import { useI18n, type Language } from '@/i18n/i18n'; +import { cn } from '@/lib/cn'; + +const languages: Array<{ value: Language; label: string }> = [ + { value: 'ru', label: 'RU' }, + { value: 'en', label: 'EN' }, +]; + +export function LanguageSwitcher({ className }: { className?: string }) { + const { language, setLanguage, t } = useI18n(); + + return ( +
+ + {languages.map((item) => ( + + ))} +
+ ); +} diff --git a/apps/frontend/src/components/Layout/Footer.tsx b/apps/frontend/src/components/Layout/Footer.tsx index 76d9484..b5d933d 100644 --- a/apps/frontend/src/components/Layout/Footer.tsx +++ b/apps/frontend/src/components/Layout/Footer.tsx @@ -2,12 +2,14 @@ import { useQuery } from '@tanstack/react-query'; import { Gift } from 'lucide-react'; import { api } from '@/lib/api'; import { FRONTEND_VERSION } from '@/lib/version'; +import { useI18n } from '@/i18n/i18n'; interface VersionInfo { backend: string; } export function Footer() { + const { t } = useI18n(); const { data } = useQuery({ queryKey: ['version'], queryFn: () => api.get('/api/version'), @@ -19,12 +21,12 @@ export function Footer() {
- Family Wishlist + {t('app.name')}
- frontend v{FRONTEND_VERSION} + {t('footer.frontend', { version: FRONTEND_VERSION })} · - backend v{data?.backend ?? '...'} + {t('footer.backend', { version: data?.backend ?? '...' })}
diff --git a/apps/frontend/src/components/Layout/Header.tsx b/apps/frontend/src/components/Layout/Header.tsx index 434c4d2..84fe882 100644 --- a/apps/frontend/src/components/Layout/Header.tsx +++ b/apps/frontend/src/components/Layout/Header.tsx @@ -1,20 +1,31 @@ import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react'; +import type { ComponentType } from 'react'; import { Button } from '../ui/Button'; import { useAuthStore } from '@/features/auth/authStore'; import { cn } from '@/lib/cn'; +import { useI18n, type TranslationKey } from '@/i18n/i18n'; +import { LanguageSwitcher } from '../LanguageSwitcher'; + +type NavIcon = ComponentType<{ className?: string }>; const links = [ - { to: '/', label: 'Active', icon: Sparkles, end: true }, - { to: '/completed', label: 'Fulfilled', icon: CheckCircle2 }, - { to: '/archive', label: 'Archive', icon: Archive }, - { to: '/trash', label: 'Trash', icon: Trash2 }, -]; + { to: '/', label: 'header.active', icon: Sparkles, end: true }, + { to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 }, + { to: '/archive', label: 'header.archive', icon: Archive }, + { to: '/trash', label: 'header.trash', icon: Trash2 }, +] satisfies Array<{ + to: string; + label: TranslationKey; + icon: NavIcon; + end?: boolean; +}>; export function Header() { const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); const navigate = useNavigate(); + const { t } = useI18n(); if (!user) return null; @@ -26,9 +37,9 @@ export function Header() {
-
Family Wishlist
+
{t('app.name')}
- signed in as {user.displayName} + {t('header.signedInAs', { name: user.displayName })}
@@ -47,20 +58,21 @@ export function Header() { } > - {l.label} + {t(l.label)} ))}
+
diff --git a/apps/frontend/src/components/Layout/ProtectedRoute.tsx b/apps/frontend/src/components/Layout/ProtectedRoute.tsx index be144d1..79220fa 100644 --- a/apps/frontend/src/components/Layout/ProtectedRoute.tsx +++ b/apps/frontend/src/components/Layout/ProtectedRoute.tsx @@ -1,13 +1,17 @@ import { Navigate, useLocation } from 'react-router-dom'; import type { ReactNode } from 'react'; import { useAuthStore } from '@/features/auth/authStore'; +import { useI18n } from '@/i18n/i18n'; export function ProtectedRoute({ children }: { children: ReactNode }) { const { user, status } = useAuthStore(); const location = useLocation(); + const { t } = useI18n(); if (status !== 'ready') { return ( -
Loading...
+
+ {t('protected.loading')} +
); } if (!user) { diff --git a/apps/frontend/src/components/WishBadges/WishBadges.tsx b/apps/frontend/src/components/WishBadges/WishBadges.tsx index 4c1da65..19e7bdc 100644 --- a/apps/frontend/src/components/WishBadges/WishBadges.tsx +++ b/apps/frontend/src/components/WishBadges/WishBadges.tsx @@ -1,6 +1,7 @@ import { Check, Sparkles, Archive, Trash2 } from 'lucide-react'; import type { Wish } from '@family-wishlist/shared'; import { cn } from '@/lib/cn'; +import { useI18n } from '@/i18n/i18n'; interface Props { wish: Wish & { isNewForOwner?: boolean }; @@ -9,6 +10,7 @@ interface Props { } export function WishBadges({ wish, view, className }: Props) { + const { t } = useI18n(); const badges: JSX.Element[] = []; const isNew = @@ -17,7 +19,7 @@ export function WishBadges({ wish, view, className }: Props) { badges.push( - new + {t('wish.badge.new')} , ); } @@ -25,7 +27,7 @@ export function WishBadges({ wish, view, className }: Props) { badges.push( - fulfilled + {t('wish.badge.fulfilled')} , ); } @@ -33,7 +35,7 @@ export function WishBadges({ wish, view, className }: Props) { badges.push( - archived + {t('wish.badge.archived')} , ); } @@ -41,7 +43,7 @@ export function WishBadges({ wish, view, className }: Props) { badges.push( - trash + {t('wish.badge.trash')} , ); } diff --git a/apps/frontend/src/components/WishCard/WishCard.tsx b/apps/frontend/src/components/WishCard/WishCard.tsx index 43b9e19..143eeba 100644 --- a/apps/frontend/src/components/WishCard/WishCard.tsx +++ b/apps/frontend/src/components/WishCard/WishCard.tsx @@ -14,6 +14,7 @@ import { WishBadges } from '../WishBadges/WishBadges'; import { Button } from '../ui/Button'; import { cn } from '@/lib/cn'; import { formatPrice } from '@/lib/format'; +import { useI18n } from '@/i18n/i18n'; export type WishCardView = 'owner' | 'guest'; @@ -49,39 +50,40 @@ function WishCardInner({ footer, }: WishCardProps) { const [menuOpen, setMenuOpen] = useState(false); + const { locale, t } = useI18n(); const completed = wish.status === 'COMPLETED'; - const priceLabel = formatPrice(wish.price, wish.currency); + const priceLabel = formatPrice(wish.price, wish.currency, locale); const imageSrc = wish.imageUrl ?? '/default-gift.svg'; const actions: WishCardAction[] = []; if (view === 'owner') { if (wish.status === 'ACTIVE') { - if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit }); + if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: t('wish.action.edit'), onClick: onEdit }); if (onComplete) actions.push({ key: 'complete', icon: CheckCircle2, - label: 'Mark fulfilled', + label: t('wish.action.complete'), onClick: onComplete, }); if (onArchive) - actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive }); + actions.push({ key: 'archive', icon: Archive, label: t('wish.action.archive'), onClick: onArchive }); if (onDelete) actions.push({ key: 'delete', icon: Trash2, - label: 'Delete', + label: t('wish.action.delete'), onClick: onDelete, danger: true, }); } else if (wish.status === 'ARCHIVED') { if (onRestore) - actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore }); + actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore }); if (onDelete) actions.push({ key: 'delete', icon: Trash2, - label: 'Delete', + label: t('wish.action.delete'), onClick: onDelete, danger: true, }); @@ -90,20 +92,20 @@ function WishCardInner({ actions.push({ key: 'duplicate', icon: Copy, - label: 'Create copy as new', + label: t('wish.action.duplicate'), onClick: onDuplicate, }); if (onDelete) actions.push({ key: 'delete', icon: Trash2, - label: 'Delete', + label: t('wish.action.delete'), onClick: onDelete, danger: true, }); } else if (wish.status === 'DELETED') { if (onRestore) - actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore }); + actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore }); } } @@ -128,7 +130,7 @@ function WishCardInner({ } >
- - - {errors.title && {errors.title.message}} + + + {errors.title && ( + {translateValidation(t, errors.title.message)} + )}
- - - {errors.price && {errors.price.message as string}} + + + {errors.price && ( + + {translateValidation(t, errors.price.message as string)} + + )}
- +
- + - {errors.url && {errors.url.message as string}} + {errors.url && ( + + {translateValidation(t, errors.url.message as string)} + + )}

- We will try to pull a preview image from the link after saving. + {t('wishForm.linkHint')}

- +