feat: add i18n and avatar upload
This commit is contained in:
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border border-border bg-surface/90 p-1 text-xs shadow-card',
|
||||
className,
|
||||
)}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
<Languages className="ml-1 h-3.5 w-3.5 text-muted" aria-hidden />
|
||||
{languages.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(item.value)}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 font-semibold transition-colors',
|
||||
language === item.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted hover:bg-surface-muted hover:text-ink',
|
||||
)}
|
||||
aria-pressed={language === item.value}
|
||||
title={item.value === 'ru' ? t('language.ru') : t('language.en')}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<VersionInfo>('/api/version'),
|
||||
@@ -19,12 +21,12 @@ export function Footer() {
|
||||
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="h-4 w-4" aria-hidden />
|
||||
<span className="font-display text-sm">Family Wishlist</span>
|
||||
<span className="font-display text-sm">{t('app.name')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>frontend v{FRONTEND_VERSION}</span>
|
||||
<span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>backend v{data?.backend ?? '...'}</span>
|
||||
<span>{t('footer.backend', { version: data?.backend ?? '...' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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() {
|
||||
<Gift className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display text-lg leading-tight">Family Wishlist</div>
|
||||
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
|
||||
<div className="text-xs text-muted">
|
||||
signed in as <span className="font-medium text-ink">{user.displayName}</span>
|
||||
{t('header.signedInAs', { name: user.displayName })}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -47,20 +58,21 @@ export function Header() {
|
||||
}
|
||||
>
|
||||
<l.icon className="h-4 w-4" />
|
||||
{l.label}
|
||||
{t(l.label)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Profile settings"
|
||||
title={t('header.profileSettings')}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
Profile
|
||||
{t('header.profile')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -70,7 +82,7 @@ export function Header() {
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
{t('header.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-muted">Loading...</div>
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-muted">
|
||||
{t('protected.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
|
||||
@@ -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(
|
||||
<span className="wish-badge wish-badge--new" key="new">
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
new
|
||||
{t('wish.badge.new')}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
@@ -25,7 +27,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--completed" key="done">
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
fulfilled
|
||||
{t('wish.badge.fulfilled')}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
@@ -33,7 +35,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--archived" key="arch">
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
archived
|
||||
{t('wish.badge.archived')}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
@@ -41,7 +43,7 @@ export function WishBadges({ wish, view, className }: Props) {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--deleted" key="del">
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
trash
|
||||
{t('wish.badge.trash')}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Actions"
|
||||
aria-label={t('wish.actions')}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
@@ -177,7 +179,7 @@ function WishCardInner({
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
open link
|
||||
{t('wish.openLink')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useUpdateWish,
|
||||
useUploadWishImage,
|
||||
} from '@/features/wishes/wishes.hooks';
|
||||
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const create = useCreateWish();
|
||||
const update = useUpdateWish();
|
||||
const upload = useUploadWishImage();
|
||||
@@ -78,59 +80,74 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add a wish' : 'Edit wish'}
|
||||
title={mode === 'create' ? t('wishForm.addTitle') : t('wishForm.editTitle')}
|
||||
description={
|
||||
mode === 'create'
|
||||
? 'Tell us what you want. A link helps us grab a preview image automatically.'
|
||||
: 'Update the details of your wish.'
|
||||
? t('wishForm.addDescription')
|
||||
: t('wishForm.editDescription')
|
||||
}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{mode === 'create' ? 'Add wish' : 'Save'}
|
||||
{mode === 'create' ? t('wishForm.addSubmit') : t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" placeholder="Moka pot, size 3" {...register('title')} />
|
||||
{errors.title && <span className="field__error">{errors.title.message}</span>}
|
||||
<Label htmlFor="title">{t('wishForm.title')}</Label>
|
||||
<Input id="title" placeholder={t('wishForm.titlePlaceholder')} {...register('title')} />
|
||||
{errors.title && (
|
||||
<span className="field__error">{translateValidation(t, errors.title.message)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="field">
|
||||
<Label htmlFor="price">Price (optional)</Label>
|
||||
<Input id="price" placeholder="e.g. 2490" inputMode="decimal" {...register('price')} />
|
||||
{errors.price && <span className="field__error">{errors.price.message as string}</span>}
|
||||
<Label htmlFor="price">{t('wishForm.price')}</Label>
|
||||
<Input
|
||||
id="price"
|
||||
placeholder={t('wishForm.pricePlaceholder')}
|
||||
inputMode="decimal"
|
||||
{...register('price')}
|
||||
/>
|
||||
{errors.price && (
|
||||
<span className="field__error">
|
||||
{translateValidation(t, errors.price.message as string)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Label htmlFor="currency">{t('wishForm.currency')}</Label>
|
||||
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="url">Link (optional)</Label>
|
||||
<Label htmlFor="url">{t('wishForm.link')}</Label>
|
||||
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
||||
{errors.url && <span className="field__error">{errors.url.message as string}</span>}
|
||||
{errors.url && (
|
||||
<span className="field__error">
|
||||
{translateValidation(t, errors.url.message as string)}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
We will try to pull a preview image from the link after saving.
|
||||
{t('wishForm.linkHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="comment">Comment (optional)</Label>
|
||||
<Label htmlFor="comment">{t('wishForm.comment')}</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
placeholder="Size / color / notes..."
|
||||
placeholder={t('wishForm.commentPlaceholder')}
|
||||
{...register('comment')}
|
||||
/>
|
||||
</div>
|
||||
@@ -140,7 +157,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Image
|
||||
{t('wishForm.image')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
@@ -165,7 +182,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Upload custom
|
||||
{t('wishForm.uploadCustom')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -178,7 +195,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
Refresh from link
|
||||
{t('wishForm.refreshFromLink')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -187,7 +204,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
disabled={resetImage.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Reset to default
|
||||
{t('wishForm.resetImage')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user