feat: add language picker to logged-in footer and editor settings
Build and Deploy Verso / deploy (push) Successful in 18m46s
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -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" />
|
||||||
|
|
||||||
<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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user