From a46ff44f9942cbd03ec44d7bc6c9bd1264134463 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 26 Dec 2025 18:45:53 +0200 Subject: [PATCH] Add multi-lingual support --- common/src/constants.ts | 8 +++ web/components/home/home.tsx | 5 +- web/components/language/language-picker.tsx | 24 +++++++ web/lib/locale-cookie.ts | 53 +++++++++++++++ web/lib/locale.ts | 44 +++++++++++++ web/messages/fr.json | 34 ++++++++++ web/pages/_app.tsx | 32 +++++++-- web/pages/_document.tsx | 4 +- web/pages/settings.tsx | 73 +++++++++++---------- 9 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 web/components/language/language-picker.tsx create mode 100644 web/lib/locale-cookie.ts create mode 100644 web/lib/locale.ts create mode 100644 web/messages/fr.json diff --git a/common/src/constants.ts b/common/src/constants.ts index 8d1b773f..0efd4ca4 100644 --- a/common/src/constants.ts +++ b/common/src/constants.ts @@ -27,3 +27,11 @@ export const WEB_GOOGLE_CLIENT_ID = '253367029065-khkj31qt22l0vc3v754h09vhpg6t33 // export const ANDROID_GOOGLE_CLIENT_ID = '253367029065-s9sr5vqgkhc8f7p5s6ti6a4chqsrqgc4.apps.googleusercontent.com' export const GOOGLE_CLIENT_ID = WEB_GOOGLE_CLIENT_ID +export const defaultLocale = 'en' +export const LOCALES = { + en: "English", + fr: "Français", + // es: "Español", +} +export const supportedLocales = Object.keys(LOCALES) +export type Locale = typeof supportedLocales[number] \ No newline at end of file diff --git a/web/components/home/home.tsx b/web/components/home/home.tsx index cc68b3ac..020f74d6 100644 --- a/web/components/home/home.tsx +++ b/web/components/home/home.tsx @@ -2,6 +2,7 @@ import {useEffect} from "react"; import {Col} from "web/components/layout/col"; import {SignUpButton} from "web/components/nav/sidebar"; import {useUser} from "web/hooks/use-user"; +import {useT} from "web/lib/locale"; export function AboutBox(props: { title: string @@ -20,6 +21,7 @@ export function AboutBox(props: { export function LoggedOutHome() { const user = useUser() + const t = useT() useEffect(() => { const text = "Search."; @@ -80,8 +82,7 @@ export function LoggedOutHome() {

- Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to - browsing — a public digital good designed to serve people, not profit. + {t('home.bottom', 'Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.')}

diff --git a/web/components/language/language-picker.tsx b/web/components/language/language-picker.tsx new file mode 100644 index 00000000..47cc3032 --- /dev/null +++ b/web/components/language/language-picker.tsx @@ -0,0 +1,24 @@ +'use client' + +import {useLocale} from "web/lib/locale"; +import {LOCALES} from "common/constants"; + + +export function LanguagePicker() { + const {locale, setLocale} = useLocale() + + return ( + + ) +} diff --git a/web/lib/locale-cookie.ts b/web/lib/locale-cookie.ts new file mode 100644 index 00000000..db31a4bb --- /dev/null +++ b/web/lib/locale-cookie.ts @@ -0,0 +1,53 @@ +import type {IncomingMessage} from 'http' +import {defaultLocale, supportedLocales, Locale} from "common/constants" + +let cachedLocale: string | null | undefined = null + +export function getLocale(req?: IncomingMessage): string { + // if (cachedLocale) return cachedLocale + console.log('cachedLocale', cachedLocale) + let cookie = null + // Server + if (req?.headers?.cookie) { + cookie = req.headers.cookie + } + + // Client + if (typeof document !== 'undefined') { + cookie = document.cookie + } + + if (cookie) { + console.log('Cookie', cookie) + cachedLocale = cookie + .split(' ') + .find(c => c.startsWith('lang=')) + ?.split('=')[1] + ?.split(' ')[0] + console.log('Locale cookie', cachedLocale) + } + + if (!cachedLocale) { + cachedLocale = getBrowserLocale() + } + + console.log('Locale cookie browser', getBrowserLocale()) + + return cachedLocale ?? defaultLocale +} + +export function getBrowserLocale(): Locale | null { + if (typeof navigator === 'undefined') return null + + const languages = navigator.languages ?? [navigator.language] + console.log('Browser languages', languages, navigator.language) + + for (const lang of languages) { + const base = lang.split('-')[0] as Locale + if (supportedLocales.includes(base)) { + return base + } + } + + return null +} diff --git a/web/lib/locale.ts b/web/lib/locale.ts new file mode 100644 index 00000000..7cc704b2 --- /dev/null +++ b/web/lib/locale.ts @@ -0,0 +1,44 @@ +import {createContext, useContext, useEffect, useState} from 'react' +import {defaultLocale} from "common/constants"; + + +export type I18nContextType = { + locale: string + setLocale: (locale: string) => void +} + +export const I18nContext = createContext({ + locale: defaultLocale, + setLocale: () => {} +}) + +export function useLocale() { + return useContext(I18nContext) +} + +// export function t(key: string, english: string): string { +// const locale = useLocale() +// console.log({locale}) +// +// if (locale === defaultLocale) return english +// return messages[locale]?.[key] ?? english +// } + +export function useT() { + const {locale} = useLocale() + console.log({locale}) + const [messages, setMessages] = useState>({}) + + useEffect(() => { + if (locale === defaultLocale) return + + import(`web/messages/${locale}.json`) + .then((mod) => setMessages(mod.default)) + .catch(() => setMessages({})) + }, [locale]) + + return (key: string, english: string) => { + if (locale === defaultLocale) return english + return messages[key] ?? english + } +} diff --git a/web/messages/fr.json b/web/messages/fr.json new file mode 100644 index 00000000..49c5c725 --- /dev/null +++ b/web/messages/fr.json @@ -0,0 +1,34 @@ +{ + "settings.title": "Paramètres", + "settings.tabs.general": "Général", + "settings.tabs.notifications": "Notifications", + "settings.tabs.about": "A propos", + "settings.general.theme": "Thème", + "settings.general.language": "Langue", + "settings.general.account": "Compte", + "settings.general.email": "Courriel", + "settings.email.verified": "Courriel vérifié ✔️", + "settings.email.send_verification": "Envoyer un courriel de vérification", + "settings.email.change": "Modifier l'adresse e‑mail", + "settings.email.new_placeholder": "Nouvelle adresse e‑mail", + "settings.email.required": "Le courriel est requis", + "settings.email.invalid": "Adresse e‑mail invalide", + "settings.action.save": "Enregistrer", + "settings.action.cancel": "Annuler", + "settings.general.password": "Mot de passe", + "settings.password.send_reset": "Envoyer un e‑mail de réinitialisation de mot de passe", + "settings.danger_zone": "Zone dangereuse", + "settings.delete_account": "Supprimer le compte", + "settings.delete_confirm": "Êtes-vous sûr de vouloir supprimer votre profil ? Cela ne peut pas être annulé.", + "settings.delete.loading": "Suppression du compte...", + "settings.delete.success": "Votre compte a été supprimé.", + "settings.delete.error": "Échec de la suppression du compte.", + "settings.email.updated_success": "Adresse e‑mail mise à jour avec succès", + "settings.email.update_failed": "Échec de la mise à jour de l'adresse e‑mail", + "settings.email.same_as_current": "Le nouveau courriel est identique à l'actuel", + "settings.email.no_email": "Aucun courriel trouvé sur votre compte.", + "settings.email.must_sign_in": "Vous devez être connecté pour envoyer un courriel de vérification.", + "settings.email.sending": "Envoi du courriel de vérification...", + "settings.email.verification_sent": "Courriel de vérification envoyé — vérifiez votre boîte de réception et les spams.", + "settings.email.verification_failed": "Échec de l'envoi du courriel de vérification." +} \ No newline at end of file diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index a71753ee..e2c48d0a 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -1,6 +1,6 @@ -import type {AppProps} from 'next/app' +import type {AppContext, AppProps} from 'next/app' import Head from 'next/head' -import {useEffect} from 'react' +import {useEffect, useState} from 'react' import {Router} from 'next/router' import posthog from 'posthog-js' import {PostHogProvider} from 'posthog-js/react' @@ -20,6 +20,8 @@ import {useRouter} from "next/navigation" import {Keyboard} from "@capacitor/keyboard" import {LiveUpdate} from "@capawesome/capacitor-live-update" import {IS_VERCEL} from "common/hosting/constants" +import {getLocale} from "web/lib/locale-cookie"; +import {I18nContext} from "web/lib/locale" if (Capacitor.isNativePlatform()) { // Only runs on iOS/Android native @@ -85,11 +87,18 @@ function printBuildInfo() { // specially treated props that may be present in the server/static props type PageProps = { auth?: AuthUser } -function MyApp({Component, pageProps}: AppProps) { +function MyApp(props: AppProps & { locale: string }) { + const {Component, pageProps} = props useEffect(printBuildInfo, []) useHasLoaded() const router = useRouter() + const [locale, setLocaleState] = useState(props.locale); + const setLocale = (newLocale: string) => { + document.cookie = `lang=${newLocale}; path=/; max-age=31536000`; + setLocaleState(newLocale); + }; + useEffect(() => { console.log('isAndroidWebView app:', isAndroidApp()) if (!Capacitor.isNativePlatform()) return @@ -142,6 +151,7 @@ function MyApp({Component, pageProps}: AppProps) { {title} + ) { - + + + {/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}
@@ -193,10 +205,18 @@ function MyApp({Component, pageProps}: AppProps) {
- {/* TODO: Re-enable one tap setup */} - {/* */} ) } +MyApp.getInitialProps = async (appContext: AppContext) => { + const appProps = await import('next/app').then(m => m.default.getInitialProps(appContext)) + const locale = getLocale(appContext.ctx.req) + + return { + ...appProps, + locale + }; +}; + export default MyApp diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 136ed9ef..606b1e27 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -46,8 +46,8 @@ export default function Document() { "body-bg text-ink-1000", 'safe-top', )}> -
- +
+ ) diff --git a/web/pages/settings.tsx b/web/pages/settings.tsx index bfbca463..a73dbde3 100644 --- a/web/pages/settings.tsx +++ b/web/pages/settings.tsx @@ -19,19 +19,22 @@ import ThemeIcon from "web/components/theme-icon"; import {WithPrivateUser} from "web/components/user/with-user"; import {sendPasswordReset} from "web/lib/firebase/password"; import {AboutSettings} from "web/components/about-settings"; +import {LanguagePicker} from "web/components/language/language-picker"; +import {useT} from "web/lib/locale"; export default function NotificationsPage() { + const t = useT() useRedirectIfSignedOut() return ( - Settings + {t('settings.title','Settings')} }, - {title: 'Notifications', content: }, - {title: 'About', content: }, + {title: t('settings.tabs.general','General'), content: }, + {title: t('settings.tabs.notifications', 'Notifications'), content: }, + {title: t('settings.tabs.about', 'About'), content: }, ]} trackingName={'settings page'} /> @@ -52,24 +55,23 @@ const LoadedGeneralSettings = (props: { const [isChangingEmail, setIsChangingEmail] = useState(false) const {register, handleSubmit, formState: {errors}, reset} = useForm<{ newEmail: string }>() + const t = useT() const user = auth.currentUser if (!user) return null const handleDeleteAccount = async () => { - const confirmed = confirm( - 'Are you sure you want to delete your profile? This cannot be undone.' - ) + const confirmed = confirm(t('settings.delete_confirm', "Are you sure you want to delete your profile? This cannot be undone.")) if (confirmed) { toast .promise(deleteAccount(), { - loading: 'Deleting account...', + loading: t('settings.delete.loading', 'Deleting account...'), success: () => { router.push('/') - return 'Your account has been deleted.' + return t('settings.delete.success', 'Your account has been deleted.') }, error: () => { - return 'Failed to delete account.' + return t('settings.delete.error', 'Failed to delete account.') }, }) .catch(() => { @@ -83,21 +85,21 @@ const LoadedGeneralSettings = (props: { try { await updateEmail(user, newEmail) - toast.success('Email updated successfully') + toast.success(t('settings.email.updated_success', 'Email updated successfully')) setIsChangingEmail(false) reset() // Force a reload to update the UI with the new email // window.location.reload() } catch (error: any) { console.error('Error updating email:', error) - toast.error(error.message || 'Failed to update email') + toast.error(error.message || t('settings.email.update_failed', 'Failed to update email')) } } const onSubmitEmailChange = (data: { newEmail: string }) => { if (!user) return if (data.newEmail === user.email) { - toast.error('New email is the same as current email') + toast.error(t('settings.email.same_as_current', 'New email is the same as current email')) return } changeUserEmail(data.newEmail) @@ -106,18 +108,18 @@ const LoadedGeneralSettings = (props: { const sendVerificationEmail = async () => { if (!privateUser?.email) { - toast.error('No email found on your account.') + toast.error(t('settings.email.no_email', 'No email found on your account.')) return } if (!user) { - toast.error('You must be signed in to send a verification email.') + toast.error(t('settings.email.must_sign_in', 'You must be signed in to send a verification email.')) return } toast .promise(sendEmailVerification(user), { - loading: 'Sending verification email...', - success: 'Verification email sent — check your inbox and spam.', - error: 'Failed to send verification email.', + loading: t('settings.email.sending', 'Sending verification email...'), + success: t('settings.email.verification_sent', 'Verification email sent — check your inbox and spam.'), + error: t('settings.email.verification_failed', 'Failed to send verification email.'), }) .catch(() => { console.log("Failed to send verification email") @@ -128,41 +130,47 @@ const LoadedGeneralSettings = (props: { return <>
-

Theme

+

{t('settings.general.theme','Theme')}

-

Account

-
Email
+

{t('settings.general.language','Language')}

+ +

{t('settings.general.account','Account')}

+
{t('settings.general.email','Email')}
{!isChangingEmail ? ( ) : (
{errors.newEmail && ( - {errors.newEmail.message} + + {errors.newEmail.message === 'Email is required' + ? t('settings.email.required','Email is required') + : errors.newEmail.message} + )}
)} -
Password
+
{t('settings.general.password','Password')}
-
Danger Zone
+
{t('settings.danger_zone','Danger Zone')}
} -