diff --git a/src/App.tsx b/src/App.tsx index c2853081a5..4adf64b781 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,10 @@ */ import { Component, ErrorInfo, ReactNode } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Outlet, Route, Routes } from 'react-router-dom'; import { SnackbarProvider } from '@ndla/ui'; import { AlertsProvider } from './components/AlertsContext'; import AuthenticationContext from './components/AuthenticationContext'; -import { BaseNameProvider } from './components/BaseNameContext'; import AboutPage from './containers/AboutPage/AboutPage'; import AccessDenied from './containers/AccessDeniedPage/AccessDeniedPage'; import AllSubjectsPage from './containers/AllSubjectsPage/AllSubjectsPage'; @@ -53,6 +52,7 @@ import SearchPage from './containers/SearchPage/SearchPage'; import SharedFolderPage from './containers/SharedFolderPage/SharedFolderPage'; import SubjectRouting from './containers/SubjectPage/SubjectRouting'; import WelcomePage from './containers/WelcomePage/WelcomePage'; +import { LanguagePath } from './LanguagePath'; import handleError from './util/handleError'; interface State { @@ -66,8 +66,8 @@ const resourceRoutes = ( ); -class App extends Component { - constructor(props: AppProps) { +class App extends Component<{}, State> { + constructor(props: {}) { super(props); this.state = { hasError: false, @@ -87,18 +87,26 @@ class App extends Component { return ; } - return ; + return ; } } -const AppRoutes = ({ base }: AppProps) => { +const AppRoutes = () => { return ( - - - - - }> + + + + }> + + + + + } + > } /> } /> } /> @@ -219,16 +227,128 @@ const AppRoutes = ({ base }: AppProps) => { } /> } /> - - - - + } /> + } /> + } /> + }> + + + + } /> + } /> + + } /> + } + > + + + + {resourceRoutes} + + + {resourceRoutes} + + + {resourceRoutes} + + + {resourceRoutes} + + + {resourceRoutes} + + }> + + + + + + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } />} + > + } /> + + } /> + + } /> + } + /> + } + /> + + } /> + + + } /> + } /> + + } /> + } /> + } /> + + } /> + } + /> + } /> + + + } /> + } /> + + } /> + } /> + + + + } /> + } /> + + } /> + } /> + + } /> + + + } /> + } /> + } + /> + + } /> + } /> + } /> + } /> + + + + ); }; -interface AppProps { - base?: string; -} - export default App; diff --git a/src/LanguagePath.tsx b/src/LanguagePath.tsx new file mode 100644 index 0000000000..fae4d2e069 --- /dev/null +++ b/src/LanguagePath.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { useApolloClient } from '@apollo/client'; +import { useVersionHash } from './components/VersionHashContext'; +import config from './config'; +import { LocaleType } from './interfaces'; +import { createApolloLinks } from './util/apiHelpers'; + +export const LanguagePath = () => { + const { i18n } = useTranslation(); + const { lang } = useParams(); + const client = useApolloClient(); + const versionHash = useVersionHash(); + + useEffect(() => { + if ( + (!lang && i18n.language !== config.defaultLocale) || + (lang && i18n.language !== lang) + ) { + i18n.changeLanguage(lang as LocaleType); + } + }, [i18n, lang]); + + i18n.on('languageChanged', (lang) => { + client.resetStore(); + client.setLink(createApolloLinks(lang, versionHash)); + document.documentElement.lang = lang; + }); + + return null; +}; diff --git a/src/__tests__/toLanguagePath-test.ts b/src/__tests__/toLanguagePath-test.ts new file mode 100644 index 0000000000..b219a48e8b --- /dev/null +++ b/src/__tests__/toLanguagePath-test.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { toLanguagePath } from '../toLanguagePath'; + +describe('toLanguagePath', () => { + it('should return path if language is nb', () => { + expect(toLanguagePath('/path', 'nb')).toEqual('/path'); + }); + + it('should return path with language if path does not start with /', () => { + expect(toLanguagePath('path', 'nn')).toEqual('/nn/path'); + }); + + it('should return path with language if path starts with /', () => { + expect(toLanguagePath('/path', 'nn')).toEqual('/nn/path'); + }); + + it('should return path with language if path starts with / and language is nb', () => { + expect(toLanguagePath('/path', 'nb')).toEqual('/path'); + }); + it('should return path with language if path starts with /nn/ and language is nn', () => { + expect(toLanguagePath('/nn/path', 'nn')).toEqual('/nn/path'); + }); + it('should return path with language if path starts with /nn/ and language is en', () => { + expect(toLanguagePath('/nn/path', 'en')).toEqual('/en/path'); + }); + it('should return path without language if path starts with /nn/ and language is nb', () => { + expect(toLanguagePath('/nn/path', 'nb')).toEqual('/path'); + }); + it('languages without translations should redirect to en', () => { + expect(toLanguagePath('/path', 'uk')).toEqual('/en/path'); + }); + it('languages without translations not starting with / should redirect to en', () => { + expect(toLanguagePath('path', 'uk')).toEqual('/en/path'); + }); +}); diff --git a/src/client.tsx b/src/client.tsx index 9f62b7dfd4..d2d44c1737 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -9,13 +9,13 @@ import './style/index.css'; //@ts-ignore import queryString from 'query-string'; -import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ReactNode } from 'react'; import { useDeviceSelectors } from 'react-device-detect'; import { createRoot, hydrateRoot } from 'react-dom/client'; import { HelmetProvider } from 'react-helmet-async'; -import { I18nextProvider, useTranslation } from 'react-i18next'; +import { I18nextProvider } from 'react-i18next'; import { BrowserRouter, MemoryRouter } from 'react-router-dom'; -import { ApolloProvider, useApolloClient } from '@apollo/client'; +import { ApolloProvider } from '@apollo/client'; import createCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; import '@fontsource/source-code-pro/400-italic.css'; @@ -33,20 +33,13 @@ import '@fontsource/source-serif-pro/index.css'; // @ts-ignore import ErrorReporter from '@ndla/error-reporter'; import { i18nInstance } from '@ndla/ui'; -import { getCookie, setCookie } from '@ndla/util'; import App from './App'; import { VersionHashProvider } from './components/VersionHashContext'; -import { getDefaultLocale } from './config'; -import { EmotionCacheKey, STORED_LANGUAGE_COOKIE_KEY } from './constants'; -import { - getLocaleInfoFromPath, - initializeI18n, - isValidLocale, - supportedLanguages, -} from './i18n'; +import { EmotionCacheKey } from './constants'; +import { getLocaleInfoFromPath, initializeI18n, isValidLocale } from './i18n'; import { NDLAWindow } from './interfaces'; import { UserAgentProvider } from './UserAgentContext'; -import { createApolloClient, createApolloLinks } from './util/apiHelpers'; +import { createApolloClient } from './util/apiHelpers'; declare global { interface Window extends NDLAWindow {} @@ -56,10 +49,10 @@ const { DATA: { config, serverPath, serverQuery }, } = window; -const { basepath, abbreviation } = getLocaleInfoFromPath(serverPath ?? ''); +const { basepath } = getLocaleInfoFromPath(serverPath ?? ''); const paths = window.location.pathname.split('/'); -const basename = isValidLocale(paths[1] ?? '') ? `${paths[1]}` : undefined; +const lang = isValidLocale(paths[1] ?? '') ? `${paths[1]}` : undefined; const { versionHash } = queryString.parse(window.location.search); @@ -71,20 +64,20 @@ const locationFromServer = { search: serverQueryString ? `?${serverQueryString}` : '', }; -const maybeStoredLanguage = getCookie( - STORED_LANGUAGE_COOKIE_KEY, - document.cookie, -); +// const maybeStoredLanguage = getCookie( +// STORED_LANGUAGE_COOKIE_KEY, +// document.cookie, +// ); // Set storedLanguage to a sane value if non-existent -if (maybeStoredLanguage === null || maybeStoredLanguage === undefined) { - setCookie({ - cookieName: STORED_LANGUAGE_COOKIE_KEY, - cookieValue: abbreviation, - lax: true, - }); -} -const storedLanguage = getCookie(STORED_LANGUAGE_COOKIE_KEY, document.cookie)!; -const i18n = initializeI18n(i18nInstance, storedLanguage); +// if (maybeStoredLanguage === null || maybeStoredLanguage === undefined) { +// setCookie({ +// cookieName: STORED_LANGUAGE_COOKIE_KEY, +// cookieValue: abbreviation, +// lax: true, +// }); +// } +// const storedLanguage = getCookie(STORED_LANGUAGE_COOKIE_KEY, document.cookie)!; +const i18n = initializeI18n(i18nInstance, lang ?? config.defaultLocale); window.errorReporter = ErrorReporter.getInstance({ logglyApiKey: config.logglyApiKey, @@ -93,7 +86,7 @@ window.errorReporter = ErrorReporter.getInstance({ ignoreUrls: [], }); -const client = createApolloClient(storedLanguage, versionHash); +const client = createApolloClient(lang, versionHash); const cache = createCache({ key: EmotionCacheKey }); // Use memory router if running under google translate @@ -103,94 +96,81 @@ const isGoogleUrl = interface RCProps { children: ReactNode; - base: string; } -const RouterComponent = ({ children, base }: RCProps) => +const RouterComponent = ({ children }: RCProps) => isGoogleUrl ? ( {children} ) : ( - - {children} - + {children} ); -const constructNewPath = (newLocale?: string) => { - const regex = new RegExp(`\\/(${supportedLanguages.join('|')})($|\\/)`, ''); - const path = window.location.pathname.replace(regex, ''); - const fullPath = path.startsWith('/') ? path : `/${path}`; - const localePrefix = newLocale ? `/${newLocale}` : ''; - return `${localePrefix}${fullPath}${window.location.search}`; -}; - -const useReactPath = () => { - const [path, setPath] = useState(''); - const listenToPopstate = () => { - const winPath = window.location.pathname; - setPath(winPath); - }; - useEffect(() => { - window.addEventListener('popstate', listenToPopstate); - window.addEventListener('pushstate', listenToPopstate); - return () => { - window.removeEventListener('popstate', listenToPopstate); - window.removeEventListener('pushstate', listenToPopstate); - }; - }, []); - return path; -}; - -const LanguageWrapper = ({ basename }: { basename?: string }) => { - const { i18n } = useTranslation(); - const [base, setBase] = useState(basename ?? ''); - const firstRender = useRef(true); - const client = useApolloClient(); - const windowPath = useReactPath(); +// const constructNewPath = (newLocale?: string) => { +// const regex = new RegExp(`\\/(${supportedLanguages.join('|')})($|\\/)`, ''); +// const path = window.location.pathname.replace(regex, ''); +// const fullPath = path.startsWith('/') ? path : `/${path}`; +// const localePrefix = newLocale ? `/${newLocale}` : ''; +// return `${localePrefix}${fullPath}${window.location.search}`; +// }; +// +// const useReactPath = () => { +// const [path, setPath] = useState(''); +// const listenToPopstate = () => { +// const winPath = window.location.pathname; +// setPath(winPath); +// }; +// useEffect(() => { +// window.addEventListener('popstate', listenToPopstate); +// window.addEventListener('pushstate', listenToPopstate); +// return () => { +// window.removeEventListener('popstate', listenToPopstate); +// window.removeEventListener('pushstate', listenToPopstate); +// }; +// }, []); +// return path; +// }; + +const LanguageWrapper = () => { + // const { i18n } = useTranslation(); + // const [base, setBase] = useState(basename ?? ''); + // const firstRender = useRef(true); + // const client = useApolloClient(); + // const windowPath = useReactPath(); const [selectors] = useDeviceSelectors(window.navigator.userAgent); - i18n.on('languageChanged', (lang) => { - setCookie({ - cookieName: STORED_LANGUAGE_COOKIE_KEY, - cookieValue: lang, - lax: true, - }); - client.resetStore(); - client.setLink(createApolloLinks(lang, versionHash)); - document.documentElement.lang = lang; - }); - - // handle path changes when the language is changed - useLayoutEffect(() => { - if (firstRender.current) { - firstRender.current = false; - } else { - window.history.replaceState('', '', constructNewPath(i18n.language)); - setBase(i18n.language); - } - }, [i18n.language]); + // + // // handle path changes when the language is changed + // useLayoutEffect(() => { + // if (firstRender.current) { + // firstRender.current = false; + // } else { + // window.history.replaceState('', '', constructNewPath(i18n.language)); + // setBase(i18n.language); + // } + // }, [i18n.language]); // handle initial redirect if URL has wrong or missing locale prefix. // only relevant when disableSSR=true - useLayoutEffect(() => { - const storedLanguage = getCookie( - STORED_LANGUAGE_COOKIE_KEY, - document.cookie, - )!; - if (storedLanguage === getDefaultLocale() && !base) return; - if (isValidLocale(storedLanguage) && storedLanguage === base) { - setBase(storedLanguage); - } - if (window.location.pathname.includes('/login/success')) return; - setBase(storedLanguage); - window.history.replaceState('', '', constructNewPath(storedLanguage)); - }, [base, windowPath]); + // useLayoutEffect(() => { + // const storedLanguage = getCookie( + // STORED_LANGUAGE_COOKIE_KEY, + // document.cookie, + // )!; + // if (storedLanguage === getDefaultLocale() && !base) return; + // if (isValidLocale(storedLanguage) && storedLanguage === base) { + // setBase(storedLanguage); + // } + // if (window.location.pathname.includes('/login/success')) return; + // setBase(storedLanguage); + // window.history.replaceState('', '', constructNewPath(storedLanguage)); + // }, [base, windowPath]); return ( - - + + ); @@ -212,7 +192,7 @@ renderOrHydrate( - + diff --git a/src/components/FeideLoginButton/FeideLoginButton.tsx b/src/components/FeideLoginButton/FeideLoginButton.tsx index 8034079f0f..d4e3177ee8 100644 --- a/src/components/FeideLoginButton/FeideLoginButton.tsx +++ b/src/components/FeideLoginButton/FeideLoginButton.tsx @@ -8,7 +8,7 @@ import { ReactNode, useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import styled from '@emotion/styled'; import { ButtonV2 as Button, ButtonV2 } from '@ndla/button'; import { colors, spacing } from '@ndla/core'; @@ -26,7 +26,6 @@ import { UserInfo } from '../../containers/MyNdla/components/UserInfo'; import { useIsNdlaFilm } from '../../routeHelpers'; import { constructNewPath, toHref } from '../../util/urlHelper'; import { AuthContext } from '../AuthenticationContext'; -import { useBaseName } from '../BaseNameContext'; import LoginModalContent from '../MyNdla/LoginModalContent'; const FeideFooterButton = styled(Button)` @@ -76,7 +75,7 @@ const FeideLoginButton = ({ footer, children }: Props) => { const location = useLocation(); const { t } = useTranslation(); const { authenticated, user } = useContext(AuthContext); - const basename = useBaseName(); + const { lang } = useParams(); const ndlaFilm = useIsNdlaFilm(); if (authenticated && !footer) { @@ -132,7 +131,7 @@ const FeideLoginButton = ({ footer, children }: Props) => { onClick={() => { window.location.href = constructNewPath( `/logout?state=${toHref(location)}`, - basename, + lang, ); }} > diff --git a/src/components/SocialMediaMetadata.tsx b/src/components/SocialMediaMetadata.tsx index 15b0d8c698..57c63f3150 100644 --- a/src/components/SocialMediaMetadata.tsx +++ b/src/components/SocialMediaMetadata.tsx @@ -8,8 +8,7 @@ import { ReactNode } from 'react'; import { Helmet } from 'react-helmet-async'; -import { useLocation, Location } from 'react-router-dom'; -import { useBaseName } from './BaseNameContext'; +import { useLocation, Location, useParams } from 'react-router-dom'; import config from '../config'; import { preferredLocales, isValidLocale } from '../i18n'; @@ -51,8 +50,8 @@ export const getAlternateLanguages = (trackableContent?: TrackableContent) => { ); }; -export const getOgUrl = (location: Location, basename: string) => { - const ogBaseName = basename === '' ? '' : `/${basename}`; +export const getOgUrl = (location: Location, basename?: string) => { + const ogBaseName = !basename?.length ? '' : `/${basename}`; return `${config.ndlaFrontendDomain}${ogBaseName}${location.pathname}`; }; @@ -81,7 +80,7 @@ const SocialMediaMetadata = ({ type = 'article', }: Props) => { const location = useLocation(); - const basename = useBaseName(); + const { lang } = useParams(); return ( @@ -101,7 +100,7 @@ const SocialMediaMetadata = ({ - + {title && } {title && } {description && } diff --git a/src/containers/AccessDeniedPage/AccessDeniedPage.tsx b/src/containers/AccessDeniedPage/AccessDeniedPage.tsx index 7e135209f2..7fd53705c1 100644 --- a/src/containers/AccessDeniedPage/AccessDeniedPage.tsx +++ b/src/containers/AccessDeniedPage/AccessDeniedPage.tsx @@ -8,18 +8,17 @@ import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { HelmetWithTracker } from '@ndla/tracker'; import { OneColumn, ErrorResourceAccessDenied } from '@ndla/ui'; import { Status } from '../../components'; import { AuthContext } from '../../components/AuthenticationContext'; -import { useBaseName } from '../../components/BaseNameContext'; import { constructNewPath, toHref } from '../../util/urlHelper'; const AccessDenied = () => { const { t } = useTranslation(); const location = useLocation(); - const basename = useBaseName(); + const { lang } = useParams(); const { authenticated } = useContext(AuthContext); const statusCode = authenticated ? 403 : 401; @@ -32,7 +31,7 @@ const AccessDenied = () => { const route = authenticated ? 'logout' : 'login'; window.location.href = constructNewPath( `/${route}?state=${toHref(location)}`, - basename, + lang, ); }} /> diff --git a/src/containers/Masthead/MastheadContainer.tsx b/src/containers/Masthead/MastheadContainer.tsx index f8dd548725..28df1a3fe1 100644 --- a/src/containers/Masthead/MastheadContainer.tsx +++ b/src/containers/Masthead/MastheadContainer.tsx @@ -9,11 +9,13 @@ import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; import { gql } from '@apollo/client'; import styled from '@emotion/styled'; import { breakpoints, mq, spacing } from '@ndla/core'; import { Feide } from '@ndla/icons/common'; -import { Masthead, LanguageSelector, Logo } from '@ndla/ui'; +import SafeLink from '@ndla/safelink'; +import { Masthead, Logo } from '@ndla/ui'; import MastheadSearch from './components/MastheadSearch'; import MastheadDrawer from './drawer/MastheadDrawer'; @@ -26,9 +28,10 @@ import { GQLMastHeadQuery, GQLMastHeadQueryVariables, } from '../../graphqlTypes'; -import { supportedLanguages } from '../../i18n'; import { useIsNdlaFilm, useUrnIds } from '../../routeHelpers'; +import { toLanguagePath } from '../../toLanguagePath'; import { useGraphQuery } from '../../util/runQueries'; +import { constructNewPath } from '../../util/urlHelper'; import ErrorBoundary from '../ErrorPage/ErrorBoundary'; const FeideLoginLabel = styled.span` @@ -39,6 +42,8 @@ const FeideLoginLabel = styled.span` const LanguageSelectWrapper = styled.div` margin-left: ${spacing.xxsmall}; + display: flex; + gap: ${spacing.small}; ${mq.range({ until: breakpoints.desktop })} { display: none; } @@ -79,6 +84,7 @@ const MastheadContainer = () => { const { user } = useContext(AuthContext); const { openAlerts, closeAlert } = useAlerts(); const ndlaFilm = useIsNdlaFilm(); + const location = useLocation(); const { data: freshData, previousData } = useGraphQuery< GQLMastHeadQuery, GQLMastHeadQueryVariables @@ -111,7 +117,7 @@ const MastheadContainer = () => { { - + + Bokmål + + + Nynorsk + {config.feideEnabled && ( diff --git a/src/containers/MyNdla/MyProfile/MyProfilePage.tsx b/src/containers/MyNdla/MyProfile/MyProfilePage.tsx index 42d083cbe1..3f492e5974 100644 --- a/src/containers/MyNdla/MyProfile/MyProfilePage.tsx +++ b/src/containers/MyNdla/MyProfile/MyProfilePage.tsx @@ -8,7 +8,7 @@ import { useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import styled from '@emotion/styled'; import { ButtonV2 } from '@ndla/button'; import { spacing } from '@ndla/core'; @@ -26,7 +26,6 @@ import { HelmetWithTracker, useTracker } from '@ndla/tracker'; import { Heading, Text } from '@ndla/typography'; import MyPreferences from './components/MyPreferences'; import { AuthContext } from '../../../components/AuthenticationContext'; -import { useBaseName } from '../../../components/BaseNameContext'; import { getAllDimensions } from '../../../util/trackingUtil'; import { constructNewPath, toHref } from '../../../util/urlHelper'; import MyContactArea from '../components/MyContactArea'; @@ -65,7 +64,7 @@ const ButtonContainer = styled.div` const MyProfilePage = () => { const { user } = useContext(AuthContext); const { t } = useTranslation(); - const basename = useBaseName(); + const { lang } = useParams(); const location = useLocation(); const { trackPageView } = useTracker(); const { deletePersonalData } = useDeletePersonalData(); @@ -81,7 +80,7 @@ const MyProfilePage = () => { await deletePersonalData(); window.location.href = constructNewPath( `/logout?state=${toHref(location)}`, - basename, + lang, ); }; diff --git a/src/containers/Page/Layout.tsx b/src/containers/Page/Layout.tsx index 7ed682d4e4..21df58ea34 100644 --- a/src/containers/Page/Layout.tsx +++ b/src/containers/Page/Layout.tsx @@ -25,6 +25,7 @@ import config from '../../config'; import { useIsNdlaFilm, useUrnIds } from '../../routeHelpers'; import { usePrevious } from '../../util/utilityHooks'; import Masthead from '../Masthead'; +import { LanguagePath } from '../../LanguagePath'; const BottomPadding = styled.div` padding-bottom: ${spacing.large}; @@ -42,7 +43,11 @@ const StyledPageContainer = styled(PageContainer)` } `; -const Layout = () => { +interface Props { + includeLanguageSwitcher?: boolean; +} + +const Layout = ({ includeLanguageSwitcher }: Props) => { const { t, i18n } = useTranslation(); const { pathname } = useLocation(); const { height } = useMastheadHeight(); @@ -87,6 +92,7 @@ const Layout = () => { data-film={ndlaFilm} > + {includeLanguageSwitcher && } { to: 'https://ndla.no/about/utlysninger', external: false, }, + { + text: 'Davvisámegiella', + to: 'se/subject:e474cd73-5b8a-42cf-b0f1-b027e522057c', + external: false, + }, + { + text: 'Українська', + to: 'en/subject:27e8623d-c092-4f00-9a6f-066438d6c466', + external: false, + }, ]; const privacyLinks = [ diff --git a/src/containers/PrivateRoute/PrivateRoute.tsx b/src/containers/PrivateRoute/PrivateRoute.tsx index f7e79ade2a..6613e596d1 100644 --- a/src/containers/PrivateRoute/PrivateRoute.tsx +++ b/src/containers/PrivateRoute/PrivateRoute.tsx @@ -6,10 +6,9 @@ * */ import { ReactElement, useContext } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { NoSSR } from '@ndla/util'; import { AuthContext } from '../../components/AuthenticationContext'; -import { useBaseName } from '../../components/BaseNameContext'; import { constructNewPath, toHref } from '../../util/urlHelper'; interface Props { @@ -19,12 +18,12 @@ interface Props { const ClientPrivateRoute = ({ element }: Props) => { const { authenticated } = useContext(AuthContext); const location = useLocation(); - const basename = useBaseName(); + const { lang } = useParams(); if (!authenticated) { window.location.href = constructNewPath( `/login?state=${toHref(location)}`, - basename, + lang, ); return null; } diff --git a/src/containers/ProgrammePage/ProgrammeContainer.tsx b/src/containers/ProgrammePage/ProgrammeContainer.tsx index 52166d966a..c9f989ab97 100644 --- a/src/containers/ProgrammePage/ProgrammeContainer.tsx +++ b/src/containers/ProgrammePage/ProgrammeContainer.tsx @@ -16,6 +16,7 @@ import { AuthContext } from '../../components/AuthenticationContext'; import SocialMediaMetadata from '../../components/SocialMediaMetadata'; import { SKIP_TO_CONTENT_ID } from '../../constants'; import { LocaleType } from '../../interfaces'; +import { toLanguagePath } from '../../toLanguagePath'; import { htmlTitle } from '../../util/titleHelper'; import { getAllDimensions } from '../../util/trackingUtil'; @@ -87,7 +88,10 @@ interface Props { grade: string; } -export const mapGradesData = (grades: GradeResult[]): GradesData[] => { +export const mapGradesData = ( + grades: GradeResult[], + currentLanguage: string, +): GradesData[] => { return grades?.map((grade) => { let foundProgrammeSubject = false; const categories = grade.categories?.map((category) => { @@ -96,7 +100,10 @@ export const mapGradesData = (grades: GradeResult[]): GradesData[] => { const categorySubjects = category.subjects?.map((subject) => { return { label: subject.subjectpage?.about?.title || subject.name || '', - url: subject.path, + url: toLanguagePath( + subject.path, + subject.metadata.customFields.language ?? currentLanguage, + ), }; }); return { @@ -115,9 +122,9 @@ export const mapGradesData = (grades: GradeResult[]): GradesData[] => { const ProgrammeContainer = ({ programme, grade }: Props) => { const { user, authContextLoaded } = useContext(AuthContext); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const heading = programme.title.title; - const grades = mapGradesData(programme.grades || []); + const grades = mapGradesData(programme.grades || [], i18n.language); const socialMediaTitle = `${programme.title.title} - ${grade}`; const metaDescription = programme.metaDescription; const image = programme.desktopImage?.url || ''; diff --git a/src/containers/Resources/Resources.tsx b/src/containers/Resources/Resources.tsx index e69350ebbd..37465422c6 100644 --- a/src/containers/Resources/Resources.tsx +++ b/src/containers/Resources/Resources.tsx @@ -27,6 +27,7 @@ import { } from '../../graphqlTypes'; import { HeadingType } from '../../interfaces'; import { useIsNdlaFilm, useUrnIds } from '../../routeHelpers'; +import { toLanguagePath } from '../../toLanguagePath'; import { contentTypeMapping } from '../../util/getContentType'; interface Props { @@ -45,7 +46,7 @@ const Resources = ({ const { resourceId } = useUrnIds(); const [showAdditionalResources, setShowAdditionalResources] = useState(false); const ndlaFilm = useIsNdlaFilm(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const isGrouped = useMemo( () => @@ -81,6 +82,10 @@ const Resources = ({ resources: type?.resources?.map((res) => ({ ...res, active: !!resourceId && res.id.endsWith(resourceId), + path: toLanguagePath( + res.path, + res.article?.language ?? i18n.language, + ), })), contentType: contentTypeMapping[type.id], noContentLabel: t('resource.noCoreResourcesAvailable', { @@ -95,6 +100,7 @@ const Resources = ({ const firstResourceType = resourceTypes?.[0]; return { ...res, + path: toLanguagePath(res.path, res.article?.language ?? i18n.language), active: !!resourceId && res.id.endsWith(resourceId), contentTypeName: firstResourceType?.name, contentType: firstResourceType @@ -105,6 +111,7 @@ const Resources = ({ return { groupedResources: [], ungroupedResources }; }, [ coreResources, + i18n.language, isGrouped, resourceId, resourceTypes, @@ -217,6 +224,9 @@ const resourceFragment = gql` id name } + article(convertEmbeds: true) { + language + } } `; diff --git a/src/containers/SubjectPage/components/SubjectPageContent.tsx b/src/containers/SubjectPage/components/SubjectPageContent.tsx index 419b8d4ed4..9dd1ac28cb 100644 --- a/src/containers/SubjectPage/components/SubjectPageContent.tsx +++ b/src/containers/SubjectPage/components/SubjectPageContent.tsx @@ -7,12 +7,14 @@ */ import { Dispatch, RefObject, SetStateAction, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { gql } from '@apollo/client'; import { NavigationBox, SimpleBreadcrumbItem } from '@ndla/ui'; import TopicWrapper from './TopicWrapper'; import { RELEVANCE_SUPPLEMENTARY } from '../../../constants'; import { GQLSubjectPageContent_SubjectFragment } from '../../../graphqlTypes'; import { toTopic, useIsNdlaFilm } from '../../../routeHelpers'; +import { toLanguagePath } from '../../../toLanguagePath'; import { scrollToRef } from '../subjectPageHelpers'; interface Props { @@ -28,6 +30,7 @@ const SubjectPageContent = ({ refs, setBreadCrumb, }: Props) => { + const { i18n } = useTranslation(); const ndlaFilm = useIsNdlaFilm(); useEffect(() => { if (topicIds.length) scrollToRef(refs[topicIds.length - 1]!); @@ -38,7 +41,10 @@ const SubjectPageContent = ({ ...topic, label: topic?.name, selected: topic?.id === topicIds[0], - url: toTopic(subject.id, topic?.id), + url: toLanguagePath( + toTopic(subject.id, topic?.id), + topic?.article?.language ?? i18n.language, + ), isRestrictedResource: topic.availability !== 'everyone', isAdditionalResource: topic.relevanceId === RELEVANCE_SUPPLEMENTARY, }; @@ -78,6 +84,10 @@ SubjectPageContent.fragments = { id availability relevanceId + article(convertEmbeds: true) { + id + language + } } ...TopicWrapper_Subject } diff --git a/src/containers/SubjectPage/components/Topic.tsx b/src/containers/SubjectPage/components/Topic.tsx index 78f27ec5db..1217d196fc 100644 --- a/src/containers/SubjectPage/components/Topic.tsx +++ b/src/containers/SubjectPage/components/Topic.tsx @@ -28,6 +28,7 @@ import { GQLTopic_TopicFragment, } from '../../../graphqlTypes'; import { toTopic, useIsNdlaFilm, useUrnIds } from '../../../routeHelpers'; +import { toLanguagePath } from '../../../toLanguagePath'; import { getArticleScripts } from '../../../util/getArticleScripts'; import { htmlTitle } from '../../../util/titleHelper'; import { getAllDimensions } from '../../../util/trackingUtil'; @@ -176,7 +177,10 @@ const Topic = ({ ...subtopic, label: subtopic.name, selected: subtopic.id === subTopicId, - url: toTopic(subjectId, ...topicPath, subtopic.id), + url: toLanguagePath( + toTopic(subjectId, ...topicPath, subtopic.id), + subtopic.article?.language ?? i18n.language, + ), isAdditionalResource: subtopic.relevanceId === RELEVANCE_SUPPLEMENTARY, }; }); @@ -231,6 +235,10 @@ export const topicFragments = { id name relevanceId + article(convertEmbeds: true) { + id + language + } } article(convertEmbeds: $convertEmbeds) { metaImage { diff --git a/src/containers/WelcomePage/Components/Programmes.tsx b/src/containers/WelcomePage/Components/Programmes.tsx index b299e22bd7..734599e1f0 100644 --- a/src/containers/WelcomePage/Components/Programmes.tsx +++ b/src/containers/WelcomePage/Components/Programmes.tsx @@ -18,6 +18,7 @@ import { import { spacing, breakpoints, mq, colors } from '@ndla/core'; import { Heading, Text } from '@ndla/typography'; import { ContentLoader, ProgrammeCard, ProgrammeV2 } from '@ndla/ui'; +import { toLanguagePath } from '../../../toLanguagePath'; import { useUserAgent } from '../../../UserAgentContext'; const StyledWrapper = styled.div` @@ -142,7 +143,7 @@ const Description = styled(Text)` `; const Programmes = ({ programmes, loading }: Props) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const selectors = useUserAgent(); const programmeCards = useMemo(() => { @@ -153,11 +154,11 @@ const Programmes = ({ programmes, loading }: Props) => { title={programme.title} wideImage={selectors?.isMobile ? programme.wideImage : undefined} narrowImage={selectors?.isMobile ? undefined : programme.narrowImage} - url={programme.url} + url={toLanguagePath(programme.url, i18n.language)} /> )); - }, [selectors?.isMobile, programmes]); + }, [programmes, selectors?.isMobile, i18n.language]); return ( diff --git a/src/graphqlTypes.ts b/src/graphqlTypes.ts index 8d771ceac9..80fe32be7d 100644 --- a/src/graphqlTypes.ts +++ b/src/graphqlTypes.ts @@ -4454,6 +4454,7 @@ export type GQLResources_ResourceFragment = { id: string; name: string; }>; + article?: { __typename?: 'Article'; language: string }; }; export type GQLResources_ResourceTypeDefinitionFragment = { @@ -4586,6 +4587,7 @@ export type GQLSubjectPageContent_SubjectFragment = { id: string; availability?: string; relevanceId?: string; + article?: { __typename?: 'Article'; id: number; language: string }; }>; } & GQLTopicWrapper_SubjectFragment; @@ -4606,6 +4608,7 @@ export type GQLTopic_TopicFragment = { id: string; name: string; relevanceId?: string; + article?: { __typename?: 'Article'; id: number; language: string }; }>; article?: { __typename?: 'Article'; diff --git a/src/i18n.ts b/src/i18n.ts index 92370463fe..fdcc475591 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -7,7 +7,16 @@ */ import * as datefnslocale from 'date-fns/locale'; -import { i18n } from 'i18next'; +import i18n, { i18n as i18nType } from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; +import { + messagesEN, + messagesNB, + messagesNN, + messagesSE, + messagesSMA, +} from '@ndla/ui'; import config, { getDefaultLocale } from './config'; import { LocaleType } from './interfaces'; import en from './messages/messagesEN'; @@ -79,7 +88,47 @@ export const getLocaleInfoFromPath = (path: string): RetType => { }; }; -export const initializeI18n = (i18n: i18n, language: string): i18n => { +const DETECTION_OPTIONS = { + order: ['path'], + caches: ['localStorage'], +}; + +export const supportedTranslationLanguages = [ + 'nb', + 'nn', + 'en', + 'se', + 'sma', +] as const; +const i18nInstance = i18n.use(LanguageDetector).use(initReactI18next); + +i18nInstance.init({ + compatibilityJSON: 'v3', + detection: DETECTION_OPTIONS, + fallbackLng: 'en', + supportedLngs: supportedTranslationLanguages, + resources: { + en: { + translation: messagesEN, + }, + nn: { + translation: messagesNN, + }, + nb: { + translation: messagesNB, + }, + se: { + translation: messagesSE, + }, + sma: { + translation: messagesSMA, + }, + }, +}); + +export { i18nInstance }; + +export const initializeI18n = (i18n: i18nType, language: string): i18nType => { const instance = i18n.cloneInstance({ lng: language, supportedLngs: preferredLanguages, diff --git a/src/server/routes/defaultRoute.tsx b/src/server/routes/defaultRoute.tsx index f605065630..28f35bccef 100644 --- a/src/server/routes/defaultRoute.tsx +++ b/src/server/routes/defaultRoute.tsx @@ -16,7 +16,6 @@ import { ApolloProvider } from '@apollo/client'; import createCache from '@emotion/cache'; import { CacheProvider } from '@emotion/react'; import { i18nInstance } from '@ndla/ui'; -import { getCookie } from '@ndla/util'; import App from '../../App'; import RedirectContext, { @@ -24,12 +23,8 @@ import RedirectContext, { } from '../../components/RedirectContext'; import { VersionHashProvider } from '../../components/VersionHashContext'; import config from '../../config'; -import { EmotionCacheKey, STORED_LANGUAGE_COOKIE_KEY } from '../../constants'; -import { - getLocaleInfoFromPath, - initializeI18n, - isValidLocale, -} from '../../i18n'; +import { EmotionCacheKey } from '../../constants'; +import { getLocaleInfoFromPath, initializeI18n } from '../../i18n'; import { LocaleType } from '../../interfaces'; import { TEMPORARY_REDIRECT } from '../../statusCodes'; import { UserAgentProvider } from '../../UserAgentContext'; @@ -124,13 +119,13 @@ async function doRender(req: Request) { } function getCookieLocaleOrFallback( - resCookie: string, + _resCookie: string, abbreviation: LocaleType, ) { - const cookieLocale = getCookie(STORED_LANGUAGE_COOKIE_KEY, resCookie) ?? ''; - if (cookieLocale.length && isValidLocale(cookieLocale)) { - return cookieLocale; - } + // const cookieLocale = getCookie(STORED_LANGUAGE_COOKIE_KEY, resCookie) ?? ''; + // if (cookieLocale.length && isValidLocale(cookieLocale)) { + // return cookieLocale; + // } return abbreviation; } diff --git a/src/toLanguagePath.ts b/src/toLanguagePath.ts new file mode 100644 index 0000000000..89e2ae3ffc --- /dev/null +++ b/src/toLanguagePath.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2024-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import config from './config'; +import { supportedLanguages } from './i18n'; +import { LocaleType } from './interfaces'; +import { constructNewPath } from './util/urlHelper'; + +export const toLanguagePath = (path: string, language?: string) => + language === config.defaultLocale + ? constructNewPath(path) + : constructNewPath( + path, + supportedLanguages.includes(language as LocaleType) ? language : 'en', + );