diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index d2b0c1daa..4c23a73fc 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -10,6 +10,7 @@ import useElectronEvent from './common/hooks/useElectronEvent'; import { ontimeQueryClient } from './common/queryClient'; import { connectSocket } from './common/utils/socket'; import theme from './theme/theme'; +import { TranslationProvider } from './translation/TranslationProvider'; import AppRouter from './AppRouter'; // Load Open Sans typeface @@ -52,7 +53,9 @@ function App() {
- + + + diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 86194e63c..7c2d88658 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -1,8 +1,9 @@ import { lazy, useEffect } from 'react'; -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { Navigate, Route, Routes, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import useAliases from './common/hooks-query/useAliases'; import withData from './features/viewers/ViewWrapper'; +import { useTranslation } from './translation/TranslationProvider'; const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Table = lazy(() => import('./features/table/ProtectedTable')); @@ -36,6 +37,17 @@ export default function AppRouter() { const { data } = useAliases(); const location = useLocation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { setLanguage } = useTranslation(); + + // Set output language + useEffect(() => { + const langParam = searchParams.get('lang'); + if (langParam && langParam.length === 2) { + setLanguage(searchParams.get('lang')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); // navigate if is alias route useEffect(() => { diff --git a/apps/client/src/common/components/title-card/TitleCard.tsx b/apps/client/src/common/components/title-card/TitleCard.tsx index fa16f55ca..fa785827f 100644 --- a/apps/client/src/common/components/title-card/TitleCard.tsx +++ b/apps/client/src/common/components/title-card/TitleCard.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from '../../../translation/TranslationProvider'; + import './TitleCard.scss'; interface TitleCardProps { @@ -9,13 +11,15 @@ interface TitleCardProps { export default function TitleCard(props: TitleCardProps) { const { label, title, subtitle, presenter } = props; + const { getLocalizedString } = useTranslation(); + const accent = label === 'now'; return (
{presenter} - {label} + {getLocalizedString(`common.${label}`)}
{title}
{subtitle}
diff --git a/apps/client/src/common/models/View.types.ts b/apps/client/src/common/models/View.types.ts index f860a6732..6ecbeba46 100644 --- a/apps/client/src/common/models/View.types.ts +++ b/apps/client/src/common/models/View.types.ts @@ -12,4 +12,5 @@ export type OverridableOptions = { hideOvertime?: boolean; hideMessagesOverlay?: boolean; hideEndMessage?: boolean; + language?: string; }; diff --git a/apps/client/src/features/viewers/backstage/Backstage.tsx b/apps/client/src/features/viewers/backstage/Backstage.tsx index adfcbf8c1..67ac843de 100644 --- a/apps/client/src/features/viewers/backstage/Backstage.tsx +++ b/apps/client/src/features/viewers/backstage/Backstage.tsx @@ -15,6 +15,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig'; import { getEventsWithDelay } from '../../../common/utils/eventsManager'; import { formatTime } from '../../../common/utils/time'; +import { useTranslation } from '../../../translation/TranslationProvider'; import { titleVariants } from '../common/animation'; import { TitleManager } from '../ViewWrapper'; @@ -39,6 +40,7 @@ interface BackstageProps { export default function Backstage(props: BackstageProps) { const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props; const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const { getLocalizedString } = useTranslation(); // Set window title useEffect(() => { @@ -77,7 +79,7 @@ export default function Backstage(props: BackstageProps) {
{general.title}
-
Time Now
+
{getLocalizedString('common.time_now')}
{clock}
@@ -103,15 +105,15 @@ export default function Backstage(props: BackstageProps) { />
-
Started At
+
{getLocalizedString('common.started_at')}
{startedAt}
-
Expected Finish
+
{getLocalizedString('common.expected_finish')}
{expectedFinish}
-
Stage Timer
+
{getLocalizedString('common.stage_timer')}
{stageTimer}
@@ -146,7 +148,7 @@ export default function Backstage(props: BackstageProps) {
-
Public message
+
{getLocalizedString('common.public_message')}
{publ.text}
diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index 5425c3c13..ceefc5a7c 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -9,6 +9,7 @@ import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { formatDisplay, millisToSeconds } from '../../../common/utils/dateConfig'; import getDelayTo from '../../../common/utils/getDelayTo'; import { formatTime } from '../../../common/utils/time'; +import { useTranslation } from '../../../translation/TranslationProvider'; import { fetchTimerData, TimerMessage } from './countdown.helpers'; import CountdownSelect from './CountdownSelect'; @@ -37,6 +38,7 @@ export default function Countdown(props: CountdownProps) { const { isMirrored, backstageEvents, time, selectedId, viewSettings } = props; const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [searchParams] = useSearchParams(); + const { getLocalizedString } = useTranslation(); const [follow, setFollow] = useState(null); const [runningTimer, setRunningTimer] = useState(0); @@ -112,11 +114,11 @@ export default function Countdown(props: CountdownProps) { ) : (
-
Time Now
+
{getLocalizedString('common.time_now')}
{clock}
-
{runningMessage}
+
{getLocalizedString(`countdown.${runningMessage}`)}
{formattedTimer} @@ -125,11 +127,11 @@ export default function Countdown(props: CountdownProps) {
-
Start Time
+
{getLocalizedString('common.start_time')}
{startTime}
-
End Time
+
{getLocalizedString('common.end_time')}
{endTime}
diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx index b5091ccd0..056a05673 100644 --- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx +++ b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx @@ -3,42 +3,43 @@ import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import Empty from '../../../common/components/state/Empty'; import { formatTime } from '../../../common/utils/time'; +import { useTranslation } from '../../../translation/TranslationProvider'; import { sanitiseTitle } from './countdown.helpers'; import './Countdown.scss'; - interface CountdownSelectProps { events: OntimeRundownEntry[]; } export default function CountdownSelect(props: CountdownSelectProps) { const { events } = props; - const filteredEvents = events.filter((event: OntimeRundownEntry) => event.type === SupportedEvent.Event) as OntimeEvent[]; + const { getLocalizedString } = useTranslation(); + + const filteredEvents = events.filter( + (event: OntimeRundownEntry) => event.type === SupportedEvent.Event, + ) as OntimeEvent[]; return (
- Select an event to follow + {getLocalizedString('countdown.select_event')}
    {!events.length ? ( ) : ( filteredEvents.map((event: OntimeEvent, counter: number) => { - const index = counter + 1; - const title = sanitiseTitle(event.title); - const start = formatTime(event.timeStart, { format: 'hh:mm' }); - const end = formatTime(event.timeEnd, { format: 'hh:mm' }); + const index = counter + 1; + const title = sanitiseTitle(event.title); + const start = formatTime(event.timeStart, { format: 'hh:mm' }); + const end = formatTime(event.timeEnd, { format: 'hh:mm' }); - return ( -
  • - - {`${index}. ${start} → ${end} | ${title}`} - -
  • - ); - }, - ) + return ( +
  • + {`${index}. ${start} → ${end} | ${title}`} +
  • + ); + }) )}
diff --git a/apps/client/src/features/viewers/countdown/countdown.helpers.ts b/apps/client/src/features/viewers/countdown/countdown.helpers.ts index ebdd81e8f..600674c71 100644 --- a/apps/client/src/features/viewers/countdown/countdown.helpers.ts +++ b/apps/client/src/features/viewers/countdown/countdown.helpers.ts @@ -3,23 +3,26 @@ import { OntimeEvent, Playback } from 'ontime-types'; import { TimeManagerType } from '../../../common/models/TimeManager.type'; export enum TimerMessage { - toStart = 'Time to start', - waiting = 'Waiting for event start', - running = 'Event running', - ended = 'Event ended at', + toStart = 'to_start', + waiting = 'waiting', + running = 'running', + ended = 'ended', unhandled = '', } /** * Parses string as a title */ -export const sanitiseTitle = (title: string | null) => - title ? title : '{no title}'; +export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}'); /** * Returns a parsed timer and relevant status message */ -export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selectedId: string): { message: TimerMessage, timer: number } => { +export const fetchTimerData = ( + time: TimeManagerType, + follow: OntimeEvent, + selectedId: string, +): { message: TimerMessage; timer: number } => { let message; let timer; @@ -27,17 +30,14 @@ export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selec // check that is not running message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running; timer = time.current ?? 0; - } else if (time.clock < follow.timeStart) { // if it hasnt started, we count to start message = TimerMessage.toStart; timer = follow.timeStart - time.clock; - } else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) { // if it has started, we show running timer message = TimerMessage.waiting; timer = time.current ?? 0; - } else { // running timer timer is not the one we are following @@ -48,18 +48,15 @@ export const fetchTimerData = (time: TimeManagerType, follow: OntimeEvent, selec // if it hasnt started, we count to start message = TimerMessage.toStart; timer = follow.timeStart - time.clock; - } else if (follow.timeStart <= time.clock) { // if it has started, we show running timer message = TimerMessage.waiting; timer = time.current ?? 0; - } else { // if it has ended, we show how long ago message = TimerMessage.ended; timer = follow.timeEnd; } - } else { // if it has ended, we show how long ago message = TimerMessage.ended; diff --git a/apps/client/src/features/viewers/public/Public.tsx b/apps/client/src/features/viewers/public/Public.tsx index f28f8a4c2..8619f11ae 100644 --- a/apps/client/src/features/viewers/public/Public.tsx +++ b/apps/client/src/features/viewers/public/Public.tsx @@ -12,6 +12,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { formatTime } from '../../../common/utils/time'; +import { useTranslation } from '../../../translation/TranslationProvider'; import { titleVariants } from '../common/animation'; import { TitleManager } from '../ViewWrapper'; @@ -36,6 +37,7 @@ interface BackstageProps { export default function Public(props: BackstageProps) { const { isMirrored, publ, publicTitle, time, events, publicSelectedId, general, viewSettings } = props; const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const { getLocalizedString } = useTranslation(); useEffect(() => { document.title = 'ontime - Public Screen'; @@ -57,7 +59,7 @@ export default function Public(props: BackstageProps) {
{general.title}
-
Time Now
+
{getLocalizedString('common.time_now')}
{clock}
@@ -110,7 +112,7 @@ export default function Public(props: BackstageProps) {
-
Public message
+
{getLocalizedString('common.public_message')}
{publ.text}
diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index f52c53494..e98556435 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -9,6 +9,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { formatTime } from '../../../common/utils/time'; +import { useTranslation } from '../../../translation/TranslationProvider'; import { formatTimerDisplay, getTimerByType } from '../common/viewerUtils'; import { TitleManager } from '../ViewWrapper'; @@ -47,6 +48,7 @@ interface TimerProps { export default function Timer(props: TimerProps) { const { isMirrored, general, pres, title, time, viewSettings } = props; const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); + const { getLocalizedString } = useTranslation(); useEffect(() => { document.title = 'ontime - Timer'; @@ -87,7 +89,7 @@ export default function Timer(props: TimerProps) {
-
Time Now
+
{getLocalizedString('common.time_now')}
{clock}
diff --git a/apps/client/src/translation/Translation.types.ts b/apps/client/src/translation/Translation.types.ts new file mode 100644 index 000000000..edbe13f99 --- /dev/null +++ b/apps/client/src/translation/Translation.types.ts @@ -0,0 +1 @@ +export type TranslationObject = { [key: string]: string }; diff --git a/apps/client/src/translation/TranslationProvider.jsx b/apps/client/src/translation/TranslationProvider.jsx new file mode 100644 index 000000000..814f9666c --- /dev/null +++ b/apps/client/src/translation/TranslationProvider.jsx @@ -0,0 +1,49 @@ +import React, { createContext, useCallback, useContext, useState } from 'react'; + +import { langDe } from '@/translation/languages/de'; +import { langEn } from '@/translation/languages/en'; + +const translationsList = { + en: langEn, + de: langDe, +}; +const ALLOWED_LANGUAGES = Object.keys(translationsList); +const DEFAULT_LANGUAGE = 'en'; +export const TranslationContext = createContext(undefined); + +export const TranslationProvider = ({ children }) => { + const [language, setLanguageState] = useState('en'); + + const getLocalizedString = useCallback( + (key, lang = language) => { + if (key in translationsList[lang]) { + return translationsList[lang][key]; + } else if (lang !== DEFAULT_LANGUAGE) { + return getLocalizedString(key, 'en'); + } + }, + [language], + ); + + const setLanguage = useCallback((language) => { + language = language.toLowerCase(); + if (ALLOWED_LANGUAGES.includes(language)) { + setLanguageState(language); + console.info(`Language set to ${language}`); + } else { + console.warn(`Language code ${language} does not exist.`); + } + }, []); + + const contextValue = { + getLocalizedString, + setLanguage, + }; + + return {children}; +}; + +export const useTranslation = () => { + const { getLocalizedString, setLanguage } = useContext(TranslationContext); + return { getLocalizedString, setLanguage }; +}; diff --git a/apps/client/src/translation/languages/de.ts b/apps/client/src/translation/languages/de.ts new file mode 100644 index 000000000..b78156f56 --- /dev/null +++ b/apps/client/src/translation/languages/de.ts @@ -0,0 +1,5 @@ +import { TranslationObject } from '../Translation.types'; + +export const langDe: TranslationObject = { + 'common.time_now': 'Aktuelle Zeit', +}; diff --git a/apps/client/src/translation/languages/en.ts b/apps/client/src/translation/languages/en.ts new file mode 100644 index 000000000..1f77c62d2 --- /dev/null +++ b/apps/client/src/translation/languages/en.ts @@ -0,0 +1,18 @@ +import { TranslationObject } from '../Translation.types'; + +export const langEn: TranslationObject = { + 'common.end_time': 'End Time', + 'common.expected_finish': 'Expected Finish', + 'common.now': 'Now', + 'common.next': 'Next', + 'common.public_message': 'Public message', + 'common.start_time': 'Start Time', + 'common.stage_timer': 'Stage Timer', + 'common.started_at': 'Started At', + 'common.time_now': 'Time now', + 'countdown.ended': 'Event ended at', + 'countdown.running': 'Event running', + 'countdown.select_event': 'Select an event to follow', + 'countdown.to_start': 'Time to start', + 'countdown.waiting': 'Waiting for event start', +};