mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-05 23:41:24 -05:00
Add multi-lingual support
This commit is contained in:
@@ -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]
|
||||
@@ -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>
|
||||
|
||||
24
web/components/language/language-picker.tsx
Normal file
24
web/components/language/language-picker.tsx
Normal 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
53
web/lib/locale-cookie.ts
Normal 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
44
web/lib/locale.ts
Normal 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
34
web/messages/fr.json
Normal 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 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."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -46,8 +46,8 @@ export default function Document() {
|
||||
"body-bg text-ink-1000",
|
||||
'safe-top',
|
||||
)}>
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
<Main/>
|
||||
<NextScript/>
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user