Add multi-lingual support

This commit is contained in:
MartinBraquet
2025-12-26 18:45:53 +02:00
parent 669a95bfa9
commit a46ff44f99
9 changed files with 234 additions and 43 deletions

View File

@@ -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]

View File

@@ -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() {
</div>
<div className="mt-10 max-w-xl mx-auto">
<p className="text-center">
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.')}
</p>
</div>
</div>

View File

@@ -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 (
<select
id="locale-picker"
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50"
>
{Object.entries(LOCALES).map(([key, v]) => (
<option key={key} value={key}>
{v}
</option>
))}
</select>
)
}

53
web/lib/locale-cookie.ts Normal file
View File

@@ -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
}

44
web/lib/locale.ts Normal file
View File

@@ -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<I18nContextType>({
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<Record<string, string>>({})
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
}
}

34
web/messages/fr.json Normal file
View File

@@ -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 email",
"settings.email.new_placeholder": "Nouvelle adresse email",
"settings.email.required": "Le courriel est requis",
"settings.email.invalid": "Adresse email invalide",
"settings.action.save": "Enregistrer",
"settings.action.cancel": "Annuler",
"settings.general.password": "Mot de passe",
"settings.password.send_reset": "Envoyer un email 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 email mise à jour avec succès",
"settings.email.update_failed": "Échec de la mise à jour de l'adresse email",
"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."
}

View File

@@ -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<PageProps>) {
function MyApp(props: AppProps<PageProps> & { 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<PageProps>) {
<Head>
<title>{title}</title>
<html lang={locale} />
<meta
property="og:title"
name="twitter:title"
@@ -185,7 +195,9 @@ function MyApp({Component, pageProps}: AppProps<PageProps>) {
<AuthProvider serverUser={pageProps.auth}>
<WebPush/>
<AndroidPush/>
<Component {...pageProps} />
<I18nContext.Provider value={{ locale, setLocale }}>
<Component {...pageProps} />
</I18nContext.Provider>
</AuthProvider>
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}
<div id="headlessui-portal-root">
@@ -193,10 +205,18 @@ function MyApp({Component, pageProps}: AppProps<PageProps>) {
</div>
</div>
</PostHogProvider>
{/* TODO: Re-enable one tap setup */}
{/* <GoogleOneTapSetup /> */}
</>
)
}
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

View File

@@ -46,8 +46,8 @@ export default function Document() {
"body-bg text-ink-1000",
'safe-top',
)}>
<Main/>
<NextScript/>
<Main/>
<NextScript/>
</body>
</Html>
)

View File

@@ -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 (
<PageBase trackPageView={'settings page'} className={'mx-4'}>
<NoSEO/>
<Title>Settings</Title>
<Title>{t('settings.title','Settings')}</Title>
<UncontrolledTabs
name={'settings-page'}
tabs={[
{title: 'General', content: <GeneralSettings/>},
{title: 'Notifications', content: <NotificationSettings/>},
{title: 'About', content: <AboutSettings/>},
{title: t('settings.tabs.general','General'), content: <GeneralSettings/>},
{title: t('settings.tabs.notifications', 'Notifications'), content: <NotificationSettings/>},
{title: t('settings.tabs.about', 'About'), content: <AboutSettings/>},
]}
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 <>
<div className="flex flex-col gap-2 max-w-fit">
<h3>Theme</h3>
<h3>{t('settings.general.theme','Theme')}</h3>
<ThemeIcon className="h-6 w-6"/>
<h3>Account</h3>
<h5>Email</h5>
<h3>{t('settings.general.language','Language')}</h3>
<LanguagePicker/>
<h3>{t('settings.general.account','Account')}</h3>
<h5>{t('settings.general.email','Email')}</h5>
<Button onClick={sendVerificationEmail} disabled={!privateUser?.email || isEmailVerified}>
{isEmailVerified ? 'Email Verified ✔️' : 'Send verification email'}
{isEmailVerified ? t('settings.email.verified','Email Verified ✔️') : t('settings.email.send_verification','Send verification email')}
</Button>
{!isChangingEmail ? (
<Button onClick={() => setIsChangingEmail(true)}>
Change email address
{t('settings.email.change','Change email address')}
</Button>
) : (
<form onSubmit={handleSubmit(onSubmitEmailChange)} className="flex flex-col gap-2">
<Col>
<Input
type="email"
placeholder="New email address"
placeholder={t('settings.email.new_placeholder','New email address')}
{...register('newEmail', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
message: t('settings.email.invalid','Invalid email address'),
},
})}
disabled={!user}
/>
{errors.newEmail && (
<span className="text-red-500 text-sm">{errors.newEmail.message}</span>
<span className="text-red-500 text-sm">
{errors.newEmail.message === 'Email is required'
? t('settings.email.required','Email is required')
: errors.newEmail.message}
</span>
)}
</Col>
<div className="flex gap-2">
<Button type="submit" color="green">
Save
{t('settings.action.save','Save')}
</Button>
<Button
type="button"
@@ -172,25 +180,24 @@ const LoadedGeneralSettings = (props: {
reset()
}}
>
Cancel
{t('settings.action.cancel','Cancel')}
</Button>
</div>
</form>
)}
<h5>Password</h5>
<h5>{t('settings.general.password','Password')}</h5>
<Button
onClick={() => sendPasswordReset(privateUser?.email)}
className="mb-2"
>
Send password reset email
{t('settings.password.send_reset','Send password reset email')}
</Button>
<h5>Danger Zone</h5>
<h5>{t('settings.danger_zone','Danger Zone')}</h5>
<Button color="red" onClick={handleDeleteAccount}>
Delete Account
{t('settings.delete_account','Delete Account')}
</Button>
</div>
</>
}