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

@@ -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');
}