feat: add i18n and avatar upload
This commit is contained in:
335
apps/frontend/src/i18n/i18n.tsx
Normal file
335
apps/frontend/src/i18n/i18n.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type Language = 'ru' | 'en';
|
||||
|
||||
const STORAGE_KEY = 'family-wishlist-language';
|
||||
const FALLBACK_LANGUAGE: Language = 'ru';
|
||||
|
||||
const translations = {
|
||||
ru: {
|
||||
'app.name': 'Family Wishlist',
|
||||
'language.ru': 'Русский',
|
||||
'language.en': 'English',
|
||||
'language.switch': 'Язык',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.save': 'Сохранить',
|
||||
'common.saveChanges': 'Сохранить изменения',
|
||||
'common.backHome': 'На главную',
|
||||
'common.days.one': '{count} день',
|
||||
'common.days.few': '{count} дня',
|
||||
'common.days.many': '{count} дней',
|
||||
'footer.frontend': 'frontend v{version}',
|
||||
'footer.backend': 'backend v{version}',
|
||||
'header.active': 'Активные',
|
||||
'header.fulfilled': 'Исполненные',
|
||||
'header.archive': 'Архив',
|
||||
'header.trash': 'Корзина',
|
||||
'header.signedInAs': 'вошли как {name}',
|
||||
'header.profileSettings': 'Настройки профиля',
|
||||
'header.profile': 'Профиль',
|
||||
'header.logout': 'Выйти',
|
||||
'login.title': 'С возвращением',
|
||||
'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.',
|
||||
'login.username': 'Логин',
|
||||
'login.password': 'Пароль',
|
||||
'login.submit': 'Войти',
|
||||
'login.failed': 'Не удалось войти',
|
||||
'dashboard.title': 'Ваш список желаний',
|
||||
'dashboard.description': 'Добавляйте вещи, о которых мечтаете. Публичная страница доступна по адресу ',
|
||||
'dashboard.addWish': 'Добавить желание',
|
||||
'dashboard.emptyTitle': 'Желаний пока нет',
|
||||
'dashboard.emptyText': 'Начните с того, что вам действительно хочется получить.',
|
||||
'dashboard.addFirstWish': 'Добавить первое желание',
|
||||
'archive.title': 'Архив',
|
||||
'archive.description': 'Отложенные желания. Их видите только вы, и их можно вернуть в активный список.',
|
||||
'archive.emptyTitle': 'Архив пуст',
|
||||
'archive.emptyText': 'Архивированные желания появятся здесь.',
|
||||
'completed.title': 'Исполненные',
|
||||
'completed.description': 'Желания, которые уже исполнились. Из любого можно создать новое.',
|
||||
'completed.emptyTitle': 'Исполненных желаний пока нет',
|
||||
'completed.emptyText': 'Когда желание исполнится, отметьте его, и оно появится здесь.',
|
||||
'trash.title': 'Корзина',
|
||||
'trash.description': 'Удалённые желания хранятся {days}, затем удаляются навсегда.',
|
||||
'trash.emptyTitle': 'Корзина пуста',
|
||||
'trash.emptyText': 'Удалённые желания будут появляться здесь на {days}.',
|
||||
'trash.autoRemove': 'Автоудаление через {days}',
|
||||
'profile.title': 'Профиль',
|
||||
'profile.publicPage': 'Публичная страница доступна по адресу ',
|
||||
'profile.slug': 'Slug (публичный URL)',
|
||||
'profile.displayName': 'Отображаемое имя',
|
||||
'profile.bio': 'О себе',
|
||||
'profile.avatarUrl': 'Ссылка на аватар',
|
||||
'profile.avatar': 'Аватар',
|
||||
'profile.avatarHint': 'Можно вставить ссылку или загрузить фото до 2 MB.',
|
||||
'profile.uploadAvatar': 'Загрузить фото',
|
||||
'profile.avatarTooLarge': 'Фото должно быть не больше 2 MB',
|
||||
'profile.avatarUnsupported': 'Поддерживаются JPEG, PNG, WebP и GIF',
|
||||
'profile.saved': 'Профиль сохранён',
|
||||
'profile.avatarUploaded': 'Аватар обновлён',
|
||||
'profile.saveFailed': 'Не удалось сохранить',
|
||||
'public.loadingWishes': 'Загрузка желаний...',
|
||||
'public.notFoundTitle': 'Профиль не найден',
|
||||
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
|
||||
'public.wishlistTitle': 'Список желаний {name}',
|
||||
'public.emptyTitle': 'Желаний пока нет',
|
||||
'public.emptyText': 'Загляните позже!',
|
||||
'notFound.text': 'Не удалось найти эту страницу.',
|
||||
'protected.loading': 'Загрузка...',
|
||||
'wish.action.edit': 'Редактировать',
|
||||
'wish.action.complete': 'Отметить исполненным',
|
||||
'wish.action.archive': 'В архив',
|
||||
'wish.action.delete': 'Удалить',
|
||||
'wish.action.restore': 'Восстановить',
|
||||
'wish.action.duplicate': 'Создать копию как новое',
|
||||
'wish.actions': 'Действия',
|
||||
'wish.openLink': 'открыть ссылку',
|
||||
'wish.badge.new': 'новое',
|
||||
'wish.badge.fulfilled': 'исполнено',
|
||||
'wish.badge.archived': 'архив',
|
||||
'wish.badge.trash': 'корзина',
|
||||
'wishForm.addTitle': 'Добавить желание',
|
||||
'wishForm.editTitle': 'Редактировать желание',
|
||||
'wishForm.addDescription': 'Расскажите, чего хочется. По ссылке мы попробуем подтянуть картинку.',
|
||||
'wishForm.editDescription': 'Обновите детали желания.',
|
||||
'wishForm.title': 'Название',
|
||||
'wishForm.titlePlaceholder': 'Гейзерная кофеварка, размер 3',
|
||||
'wishForm.price': 'Цена (необязательно)',
|
||||
'wishForm.pricePlaceholder': 'например, 2490',
|
||||
'wishForm.currency': 'Валюта',
|
||||
'wishForm.link': 'Ссылка (необязательно)',
|
||||
'wishForm.linkHint': 'После сохранения попробуем подтянуть превью-картинку по ссылке.',
|
||||
'wishForm.comment': 'Комментарий (необязательно)',
|
||||
'wishForm.commentPlaceholder': 'Размер / цвет / заметки...',
|
||||
'wishForm.image': 'Картинка',
|
||||
'wishForm.uploadCustom': 'Загрузить свою',
|
||||
'wishForm.refreshFromLink': 'Обновить по ссылке',
|
||||
'wishForm.resetImage': 'Сбросить на стандартную',
|
||||
'wishForm.addSubmit': 'Добавить желание',
|
||||
'toast.genericError': 'Что-то пошло не так',
|
||||
'toast.wishAdded': 'Желание добавлено',
|
||||
'toast.saved': 'Сохранено',
|
||||
'toast.archived': 'Перемещено в архив',
|
||||
'toast.fulfilled': 'Отмечено исполненным',
|
||||
'toast.deleted': 'Перемещено в корзину (30 дней на восстановление)',
|
||||
'toast.restored': 'Восстановлено',
|
||||
'toast.duplicated': 'Новое желание создано из исполненного',
|
||||
'toast.imageUpdated': 'Картинка обновлена',
|
||||
'toast.imageRefreshed': 'Картинка обновлена по ссылке',
|
||||
'toast.imageFetchFailed': 'Не удалось получить картинку по ссылке',
|
||||
'toast.imageReset': 'Картинка сброшена на стандартную',
|
||||
'validation.invalid': 'Проверьте значение поля',
|
||||
'validation.slug': 'Slug: 3-32 символа, строчные латинские буквы, цифры и дефисы',
|
||||
},
|
||||
en: {
|
||||
'app.name': 'Family Wishlist',
|
||||
'language.ru': 'Русский',
|
||||
'language.en': 'English',
|
||||
'language.switch': 'Language',
|
||||
'common.loading': 'Loading...',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.saveChanges': 'Save changes',
|
||||
'common.backHome': 'Back to home',
|
||||
'common.days.one': '{count} day',
|
||||
'common.days.few': '{count} days',
|
||||
'common.days.many': '{count} days',
|
||||
'footer.frontend': 'frontend v{version}',
|
||||
'footer.backend': 'backend v{version}',
|
||||
'header.active': 'Active',
|
||||
'header.fulfilled': 'Fulfilled',
|
||||
'header.archive': 'Archive',
|
||||
'header.trash': 'Trash',
|
||||
'header.signedInAs': 'signed in as {name}',
|
||||
'header.profileSettings': 'Profile settings',
|
||||
'header.profile': 'Profile',
|
||||
'header.logout': 'Log out',
|
||||
'login.title': 'Welcome back',
|
||||
'login.description': 'Sign in to manage your wishlist. Credentials are set up via the server environment.',
|
||||
'login.username': 'Username',
|
||||
'login.password': 'Password',
|
||||
'login.submit': 'Sign in',
|
||||
'login.failed': 'Login failed',
|
||||
'dashboard.title': 'Your wishlist',
|
||||
'dashboard.description': 'Add things you dream of. Share your public page at ',
|
||||
'dashboard.addWish': 'Add wish',
|
||||
'dashboard.emptyTitle': 'No wishes yet',
|
||||
'dashboard.emptyText': "Start by adding something you'd love to receive.",
|
||||
'dashboard.addFirstWish': 'Add your first wish',
|
||||
'archive.title': 'Archive',
|
||||
'archive.description': 'Wishes you put aside. Only you see this. Restore them to your active list any time.',
|
||||
'archive.emptyTitle': 'Archive is empty',
|
||||
'archive.emptyText': 'Archived wishes will show up here.',
|
||||
'completed.title': 'Fulfilled',
|
||||
'completed.description': "Wishes you've received. You can create a new wish based on any of them.",
|
||||
'completed.emptyTitle': 'Nothing fulfilled yet',
|
||||
'completed.emptyText': 'When a wish comes true, mark it as fulfilled and it lands here.',
|
||||
'trash.title': 'Trash',
|
||||
'trash.description': 'Deleted wishes are kept for {days}, then permanently removed.',
|
||||
'trash.emptyTitle': 'Trash is empty',
|
||||
'trash.emptyText': 'Deleted wishes will appear here for {days}.',
|
||||
'trash.autoRemove': 'Auto-removes in {days}',
|
||||
'profile.title': 'Profile',
|
||||
'profile.publicPage': 'Your public page lives at ',
|
||||
'profile.slug': 'Slug (public URL)',
|
||||
'profile.displayName': 'Display name',
|
||||
'profile.bio': 'Bio',
|
||||
'profile.avatarUrl': 'Avatar URL',
|
||||
'profile.avatar': 'Avatar',
|
||||
'profile.avatarHint': 'Paste a link or upload a photo up to 2 MB.',
|
||||
'profile.uploadAvatar': 'Upload photo',
|
||||
'profile.avatarTooLarge': 'Avatar must be 2 MB or less',
|
||||
'profile.avatarUnsupported': 'JPEG, PNG, WebP and GIF are supported',
|
||||
'profile.saved': 'Profile saved',
|
||||
'profile.avatarUploaded': 'Avatar updated',
|
||||
'profile.saveFailed': 'Save failed',
|
||||
'public.loadingWishes': 'Loading wishes...',
|
||||
'public.notFoundTitle': 'Profile not found',
|
||||
'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.',
|
||||
'public.wishlistTitle': "{name}'s wishlist",
|
||||
'public.emptyTitle': 'No wishes yet',
|
||||
'public.emptyText': 'Check back later!',
|
||||
'notFound.text': "We couldn't find that page.",
|
||||
'protected.loading': 'Loading...',
|
||||
'wish.action.edit': 'Edit',
|
||||
'wish.action.complete': 'Mark fulfilled',
|
||||
'wish.action.archive': 'Archive',
|
||||
'wish.action.delete': 'Delete',
|
||||
'wish.action.restore': 'Restore',
|
||||
'wish.action.duplicate': 'Create copy as new',
|
||||
'wish.actions': 'Actions',
|
||||
'wish.openLink': 'open link',
|
||||
'wish.badge.new': 'new',
|
||||
'wish.badge.fulfilled': 'fulfilled',
|
||||
'wish.badge.archived': 'archived',
|
||||
'wish.badge.trash': 'trash',
|
||||
'wishForm.addTitle': 'Add a wish',
|
||||
'wishForm.editTitle': 'Edit wish',
|
||||
'wishForm.addDescription': 'Tell us what you want. A link helps us grab a preview image automatically.',
|
||||
'wishForm.editDescription': 'Update the details of your wish.',
|
||||
'wishForm.title': 'Title',
|
||||
'wishForm.titlePlaceholder': 'Moka pot, size 3',
|
||||
'wishForm.price': 'Price (optional)',
|
||||
'wishForm.pricePlaceholder': 'e.g. 2490',
|
||||
'wishForm.currency': 'Currency',
|
||||
'wishForm.link': 'Link (optional)',
|
||||
'wishForm.linkHint': 'We will try to pull a preview image from the link after saving.',
|
||||
'wishForm.comment': 'Comment (optional)',
|
||||
'wishForm.commentPlaceholder': 'Size / color / notes...',
|
||||
'wishForm.image': 'Image',
|
||||
'wishForm.uploadCustom': 'Upload custom',
|
||||
'wishForm.refreshFromLink': 'Refresh from link',
|
||||
'wishForm.resetImage': 'Reset to default',
|
||||
'wishForm.addSubmit': 'Add wish',
|
||||
'toast.genericError': 'Something went wrong',
|
||||
'toast.wishAdded': 'Wish added',
|
||||
'toast.saved': 'Saved',
|
||||
'toast.archived': 'Moved to archive',
|
||||
'toast.fulfilled': 'Marked as fulfilled',
|
||||
'toast.deleted': 'Moved to trash (30 days to restore)',
|
||||
'toast.restored': 'Restored',
|
||||
'toast.duplicated': 'New wish created from the fulfilled one',
|
||||
'toast.imageUpdated': 'Image updated',
|
||||
'toast.imageRefreshed': 'Image refreshed from link',
|
||||
'toast.imageFetchFailed': 'Could not fetch image from link',
|
||||
'toast.imageReset': 'Reset to default image',
|
||||
'validation.invalid': 'Check this field',
|
||||
'validation.slug': 'Slug must be 3-32 chars, lowercase letters, digits, hyphens',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof translations.ru;
|
||||
|
||||
interface I18nContextValue {
|
||||
language: Language;
|
||||
locale: string;
|
||||
setLanguage: (language: Language) => void;
|
||||
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
||||
dayCount: (count: number) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
function isLanguage(value: string | null): value is Language {
|
||||
return value === 'ru' || value === 'en';
|
||||
}
|
||||
|
||||
function detectLanguage(): Language {
|
||||
if (typeof window === 'undefined') return FALLBACK_LANGUAGE;
|
||||
const saved = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (isLanguage(saved)) return saved;
|
||||
const languages = navigator.languages.length ? navigator.languages : [navigator.language];
|
||||
for (const lang of languages) {
|
||||
const normalized = lang.toLowerCase();
|
||||
if (normalized.startsWith('ru')) return 'ru';
|
||||
if (normalized.startsWith('en')) return 'en';
|
||||
}
|
||||
return FALLBACK_LANGUAGE;
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? `{${key}}`));
|
||||
}
|
||||
|
||||
function dayKey(language: Language, count: number): TranslationKey {
|
||||
if (language === 'en') return count === 1 ? 'common.days.one' : 'common.days.many';
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return 'common.days.one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'common.days.few';
|
||||
return 'common.days.many';
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(() => detectLanguage());
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
window.localStorage.setItem(STORAGE_KEY, language);
|
||||
}, [language]);
|
||||
|
||||
const setLanguage = useCallback((next: Language) => setLanguageState(next), []);
|
||||
const t = useCallback(
|
||||
(key: TranslationKey, vars?: Record<string, string | number>) =>
|
||||
interpolate(translations[language][key] ?? translations[FALLBACK_LANGUAGE][key], vars),
|
||||
[language],
|
||||
);
|
||||
const dayCount = useCallback(
|
||||
(count: number) => t(dayKey(language, count), { count }),
|
||||
[language, t],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
language,
|
||||
locale: language === 'ru' ? 'ru-RU' : 'en-US',
|
||||
setLanguage,
|
||||
t,
|
||||
dayCount,
|
||||
}),
|
||||
[dayCount, language, setLanguage, t],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const value = useContext(I18nContext);
|
||||
if (!value) throw new Error('useI18n must be used within I18nProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function translateValidation(t: I18nContextValue['t'], message: string | undefined): string {
|
||||
if (!message) return t('validation.invalid');
|
||||
if (message.includes('Slug must be')) return t('validation.slug');
|
||||
return t('validation.invalid');
|
||||
}
|
||||
Reference in New Issue
Block a user