feat: add language picker to logged-in footer and editor settings
Build and Deploy Verso / deploy (push) Successful in 18m46s

- Rewrites LanguagePicker to use availableLanguages from ol-footer meta
  instead of subdomainLang (which is always empty in single-domain setup)
- Passes availableLanguages through layout-react.pug → ol-footer meta so
  React footer picks it up
- Adds InterfaceLanguageSetting component to the editor settings modal
  ("Spelling and language" tab) for use when no footer is present
- Adds interface_language key to all five locale files (en/fr/de/es/it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-15 12:22:55 +00:00
parent 1a0197812d
commit 319ccc32ee
12 changed files with 87 additions and 21 deletions
@@ -580,7 +580,11 @@ async function setLanguage(req, res) {
if (userId) { if (userId) {
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec() await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
} }
const redir = req.get('Referer') || '/project' const returnTo = req.query.return_to
const redir =
typeof returnTo === 'string' && returnTo.startsWith('/')
? returnTo
: req.get('Referer') || '/project'
res.redirect(302, redir) res.redirect(302, redir)
} }
+1
View File
@@ -49,6 +49,7 @@ block append meta
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by, showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
subdomainLang: settings.i18n.subdomainLang, subdomainLang: settings.i18n.subdomainLang,
translatedLanguages: settings.translatedLanguages, translatedLanguages: settings.translatedLanguages,
availableLanguages: availableLanguages,
leftItems: cloneAndTranslateText(settings.nav.left_footer), leftItems: cloneAndTranslateText(settings.nav.left_footer),
rightItems: settings.nav.right_footer, rightItems: settings.nav.right_footer,
} }
@@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import Setting from '../setting'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
export default function InterfaceLanguageSetting() {
const { t } = useTranslation()
const currentLangCode = getMeta('ol-i18n').currentLangCode
const footerMeta = getMeta('ol-footer')
const availableLanguages: string[] = useMemo(
() => footerMeta?.availableLanguages ?? [],
[footerMeta]
)
const translatedLanguages: Record<string, string> = useMemo(
() => footerMeta?.translatedLanguages ?? {},
[footerMeta]
)
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const lng = event.target.value
const returnTo = encodeURIComponent(window.location.pathname)
window.location.href = `/set-language?lng=${encodeURIComponent(lng)}&return_to=${returnTo}`
},
[]
)
if (availableLanguages.length <= 1) return null
return (
<Setting controlId="interfaceLanguage" label={t('interface_language')}>
<OLFormSelect
id="interfaceLanguage"
className="ide-dropdown-setting ide-dropdown-setting-wide"
size="sm"
value={currentLangCode}
onChange={handleChange}
translate="no"
>
{availableLanguages
.filter(lng => translatedLanguages[lng])
.map(lng => (
<option key={lng} value={lng}>
{translatedLanguages[lng]}
</option>
))}
</OLFormSelect>
</Setting>
)
}
@@ -9,6 +9,7 @@ import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf
import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting' import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting' import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
import InterfaceLanguageSetting from '@/features/settings/components/editor-settings/interface-language-setting'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting' import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting' import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
@@ -165,6 +166,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'dictionary-settings', key: 'dictionary-settings',
component: <DictionarySetting />, component: <DictionarySetting />,
}, },
{
key: 'interfaceLanguage',
component: <InterfaceLanguageSetting />,
},
], ],
}, },
...spellcheckExtraSections, ...spellcheckExtraSections,
@@ -41,11 +41,13 @@ function Separator() {
) )
} }
function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) { function ThinFooter({
availableLanguages,
leftItems,
rightItems,
}: FooterMetadata) {
const { t } = useTranslation() const { t } = useTranslation()
const showLanguagePicker = Boolean( const showLanguagePicker = (availableLanguages?.length ?? 0) > 1
subdomainLang && Object.keys(subdomainLang).length > 1
)
return ( return (
<footer className="site-footer"> <footer className="site-footer">
@@ -14,9 +14,10 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
const { t } = useTranslation() const { t } = useTranslation()
const currentLangCode = getMeta('ol-i18n').currentLangCode const currentLangCode = getMeta('ol-i18n').currentLangCode
const translatedLanguages = getMeta('ol-footer').translatedLanguages const footerMeta = getMeta('ol-footer')
const subdomainLang = getMeta('ol-footer').subdomainLang const translatedLanguages = footerMeta?.translatedLanguages
const currentUrlWithQueryParams = window.location.pathname const availableLanguages: string[] = footerMeta?.availableLanguages ?? []
const currentUrl = window.location.pathname
return ( return (
<Dropdown drop="up"> <Dropdown drop="up">
@@ -30,7 +31,7 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
<MaterialIcon type="translate" /> <MaterialIcon type="translate" />
&nbsp; &nbsp;
<span className="language-picker-text"> <span className="language-picker-text">
{translatedLanguages?.[currentLangCode]} {translatedLanguages?.[currentLangCode] ?? currentLangCode}
</span> </span>
</DropdownToggle> </DropdownToggle>
@@ -39,24 +40,19 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
aria-labelledby="language-picker-toggle" aria-labelledby="language-picker-toggle"
> >
{showHeader ? <DropdownHeader>{t('language')}</DropdownHeader> : null} {showHeader ? <DropdownHeader>{t('language')}</DropdownHeader> : null}
{subdomainLang && {availableLanguages
Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => { .filter(lng => translatedLanguages?.[lng])
if ( .map(lng => {
!subdomainDetails || const isActive = lng === currentLangCode
!subdomainDetails.lngCode ||
subdomainDetails.hide
)
return null
const isActive = subdomainDetails.lngCode === currentLangCode
return ( return (
<li role="none" key={subdomain} translate="no"> <li role="none" key={lng} translate="no">
<DropdownItem <DropdownItem
href={`${subdomainDetails.url}${currentUrlWithQueryParams}`} href={`/set-language?lng=${encodeURIComponent(lng)}&return_to=${encodeURIComponent(currentUrl)}`}
active={isActive} active={isActive}
aria-current={isActive ? 'true' : false} aria-current={isActive ? 'true' : false}
trailingIcon={isActive ? 'check' : null} trailingIcon={isActive ? 'check' : null}
> >
{translatedLanguages?.[subdomainDetails.lngCode]} {translatedLanguages![lng]}
</DropdownItem> </DropdownItem>
</li> </li>
) )
@@ -13,6 +13,7 @@ export type FooterMetadata = {
translatedLanguages: { [key: string]: string } translatedLanguages: { [key: string]: string }
showPoweredBy?: boolean showPoweredBy?: boolean
subdomainLang?: SubdomainLang subdomainLang?: SubdomainLang
availableLanguages?: string[]
leftItems?: FooterItem[] leftItems?: FooterItem[]
rightItems?: FooterItem[] rightItems?: FooterItem[]
} }
+1
View File
@@ -2502,6 +2502,7 @@
"speak": "Sprechen", "speak": "Sprechen",
"speech_input_not_available": "Die Spracheingabe ist in diesem Browser noch nicht verfügbar", "speech_input_not_available": "Die Spracheingabe ist in diesem Browser noch nicht verfügbar",
"spellcheck": "Rechtschreibprüfung", "spellcheck": "Rechtschreibprüfung",
"interface_language": "Oberflächensprache",
"spellcheck_language": "Sprache der Rechtschreibprüfung", "spellcheck_language": "Sprache der Rechtschreibprüfung",
"spelling_and_language": "Rechtschreibung und Sprache", "spelling_and_language": "Rechtschreibung und Sprache",
"split_view": "Geteilte Ansicht", "split_view": "Geteilte Ansicht",
+1
View File
@@ -2481,6 +2481,7 @@
"speak": "Speak", "speak": "Speak",
"speech_input_not_available": "Speech input is not yet available in this browser", "speech_input_not_available": "Speech input is not yet available in this browser",
"spellcheck": "Spellcheck", "spellcheck": "Spellcheck",
"interface_language": "Interface language",
"spellcheck_language": "Spellcheck language", "spellcheck_language": "Spellcheck language",
"spelling_and_language": "Spelling and language", "spelling_and_language": "Spelling and language",
"split_view": "Split view", "split_view": "Split view",
+1
View File
@@ -2482,6 +2482,7 @@
"speak": "Hablar", "speak": "Hablar",
"speech_input_not_available": "La entrada de voz aún no está disponible en este navegador", "speech_input_not_available": "La entrada de voz aún no está disponible en este navegador",
"spellcheck": "corrector ortográfico", "spellcheck": "corrector ortográfico",
"interface_language": "Idioma de la interfaz",
"spellcheck_language": "Idioma del corrector ortográfico", "spellcheck_language": "Idioma del corrector ortográfico",
"spelling_and_language": "Ortografía y lenguaje", "spelling_and_language": "Ortografía y lenguaje",
"split_view": "Vista dividida", "split_view": "Vista dividida",
+1
View File
@@ -2486,6 +2486,7 @@
"speak": "Parler", "speak": "Parler",
"speech_input_not_available": "La saisie vocale n'est pas encore disponible dans ce navigateur", "speech_input_not_available": "La saisie vocale n'est pas encore disponible dans ce navigateur",
"spellcheck": "Vérification orthographique", "spellcheck": "Vérification orthographique",
"interface_language": "Langue de l'interface",
"spellcheck_language": "Langue de vérification orthographique", "spellcheck_language": "Langue de vérification orthographique",
"spelling_and_language": "Orthographe et langue", "spelling_and_language": "Orthographe et langue",
"split_view": "Vue fractionnée", "split_view": "Vue fractionnée",
+1
View File
@@ -2481,6 +2481,7 @@
"speak": "Parla", "speak": "Parla",
"speech_input_not_available": "L'input vocale non è ancora disponibile in questo browser", "speech_input_not_available": "L'input vocale non è ancora disponibile in questo browser",
"spellcheck": "Controllo ortografico", "spellcheck": "Controllo ortografico",
"interface_language": "Lingua dell'interfaccia",
"spellcheck_language": "Lingua del controllo ortografico", "spellcheck_language": "Lingua del controllo ortografico",
"spelling_and_language": "Ortografia e lingua", "spelling_and_language": "Ortografia e lingua",
"split_view": "Visualizzazione divisa", "split_view": "Visualizzazione divisa",