Create custom Translation component for better i18n handling (#4431)
* Initial plan * Implement Translation component with Storybook stories Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Add Jest test for Translation component and demonstrate ?lang=de functionality Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Javascript formatting autofixes * Create centralized type-safe localization system Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Add @testing-library/react to Translation component tests Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Fix code formatting errors with prettier and eslint Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Revert "Fix code formatting errors with prettier and eslint" Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * fix(js): eslinter errors * fix(js): unused code warnings * fix(js): fix additional warnings * Update Emoji admin page to use new Translation component Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Organize localization keys by logical sections (Frontend, Admin, Common, Testing) Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Organize localization keys by TypeScript namespaces Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * Javascript formatting autofixes * feat(js): add support for default translated text * chore: add default lang translations on commit * fix(js): unused code warnings * Update OfflineBanner component to use new Translation component Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * fix(js): fix localization extraction job * chore(js): remove ts-node cli * fix(css): fix css warning * feat(js): add some additional translation strings via component * chore: update extracted translations * test: add tests for Translation component defaultText and fallback behavior Co-authored-by: gabek <414923+gabek@users.noreply.github.com> * chore: update extracted translations * Javascript formatting autofixes * chore: call out new Translation component * fix: linter warning * chore: updated instructions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gabek <414923+gabek@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online> Co-authored-by: Gabe Kangas <gabek@real-ity.com>
This commit is contained in:
co-authored by
gabek
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Owncast
Gabe Kangas
parent
7b181aa0a6
commit
29b5100114
@@ -1,5 +1,7 @@
|
||||
import { Alert, Button } from 'antd';
|
||||
import { FC } from 'react';
|
||||
import Translation from '../Translation/Translation';
|
||||
import { Localization } from '../../../types/localization';
|
||||
|
||||
export type ComponentErrorProps = {
|
||||
message?: string;
|
||||
@@ -35,7 +37,15 @@ const ErrorContent = ({
|
||||
<p>You may optionally retry, however functionality might not work as expected.</p>
|
||||
)}
|
||||
<code>
|
||||
<div>{message && `Error: ${message}`}</div>
|
||||
<div>
|
||||
{message && (
|
||||
<Translation
|
||||
translationKey={Localization.Frontend.componentError}
|
||||
defaultText="Error: {{message}}"
|
||||
vars={{ message }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>Component: {componentName}</div>
|
||||
<div>{details && details}</div>
|
||||
</code>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from 'next-export-i18n';
|
||||
import styles from './Footer.module.scss';
|
||||
import { ServerStatus } from '../../../interfaces/server-status.model';
|
||||
import { serverStatusState } from '../../stores/ClientConfigStore';
|
||||
import { Localization } from '../../../types';
|
||||
import Translation from '../Translation/Translation';
|
||||
|
||||
export const Footer: FC = () => {
|
||||
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
|
||||
@@ -12,8 +14,11 @@ export const Footer: FC = () => {
|
||||
return (
|
||||
<footer className={styles.footer} id="footer">
|
||||
<span>
|
||||
{t('Powered by Owncast')}
|
||||
<a href="https://owncast.online"> v{versionNumber}</a>
|
||||
<Translation
|
||||
translationKey={Localization.Common.poweredByOwncastVersion}
|
||||
vars={{ versionNumber }}
|
||||
defaultText="Powered by <a href='https://owncast.online'>Owncast v{{versionNumber}}</a>"
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.links}>
|
||||
<a href="https://owncast.online/docs" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bodyText {
|
||||
line-height: 2rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
@@ -61,3 +56,20 @@
|
||||
color: var(--color-owncast-palette-7);
|
||||
}
|
||||
}
|
||||
|
||||
// Styles for Translation component generated spans
|
||||
.bodyText {
|
||||
:global(.notify-link),
|
||||
:global(.follow-link) {
|
||||
color: var(--theme-color-action);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-owncast-palette-7);
|
||||
}
|
||||
}
|
||||
|
||||
line-height: 2rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { Divider } from 'antd';
|
||||
import { FC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import dynamic from 'next/dynamic';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'next-export-i18n';
|
||||
import { Translation } from '../Translation/Translation';
|
||||
import { Localization } from '../../../types/localization';
|
||||
import styles from './OfflineBanner.module.scss';
|
||||
|
||||
// Lazy loaded components
|
||||
@@ -39,49 +41,49 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSpanClick = (event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
const target = event.target as HTMLSpanElement;
|
||||
if (target.classList.contains('notify-link')) {
|
||||
onNotifyClick?.();
|
||||
} else if (target.classList.contains('follow-link')) {
|
||||
onFollowClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
let text;
|
||||
if (customText) {
|
||||
text = customText;
|
||||
} else if (!customText && notificationsEnabled && fediverseAccount) {
|
||||
text = (
|
||||
<span>
|
||||
{t('This stream is offline. You can')}{' '}
|
||||
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
|
||||
be notified
|
||||
</span>{' '}
|
||||
the next time {streamName} goes live or{' '}
|
||||
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}>
|
||||
follow
|
||||
</span>{' '}
|
||||
{fediverseAccount} on the Fediverse.
|
||||
</span>
|
||||
<Translation
|
||||
translationKey={Localization.Frontend.offlineNotifyAndFediverse}
|
||||
vars={{ streamer: streamName, fediverseAccount }}
|
||||
defaultText="This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse."
|
||||
/>
|
||||
);
|
||||
} else if (!customText && notificationsEnabled) {
|
||||
text = (
|
||||
<span>
|
||||
{t('This stream is offline')}.{' '}
|
||||
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
|
||||
Be notified
|
||||
</span>{' '}
|
||||
{t('the next time goes live', { streamer: streamName })}.
|
||||
</span>
|
||||
<Translation
|
||||
translationKey={Localization.Frontend.offlineNotifyOnly}
|
||||
vars={{ streamer: streamName }}
|
||||
defaultText="This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live."
|
||||
/>
|
||||
);
|
||||
} else if (!customText && fediverseAccount) {
|
||||
text = (
|
||||
<span>
|
||||
{t('This stream is offline.')}{' '}
|
||||
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}>
|
||||
{t('Follow')}
|
||||
</span>{' '}
|
||||
{t('on the Fediverse to see the next time goes live', {
|
||||
fediverseAccount,
|
||||
streamer: streamName,
|
||||
})}
|
||||
.
|
||||
</span>
|
||||
<Translation
|
||||
translationKey={Localization.Frontend.offlineFediverseOnly}
|
||||
vars={{ fediverseAccount, streamer: streamName }}
|
||||
defaultText="This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live."
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
text = `This stream is offline. Check back soon!`;
|
||||
text = (
|
||||
<Translation
|
||||
translationKey={Localization.Frontend.offlineBasic}
|
||||
defaultText="This stream is offline. Check back soon!"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -96,7 +98,14 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
|
||||
{customText ? (
|
||||
<div className={styles.bodyText} dangerouslySetInnerHTML={{ __html: text }} />
|
||||
) : (
|
||||
<div className={styles.bodyText}>{text}</div>
|
||||
<div
|
||||
className={styles.bodyText}
|
||||
onClick={handleSpanClick}
|
||||
role="presentation"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastLive && (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React, { FC } from 'react';
|
||||
import { useTranslation } from 'next-export-i18n';
|
||||
import { LocalizationKey } from '../../../types/localization';
|
||||
|
||||
export interface TranslationProps {
|
||||
translationKey: LocalizationKey;
|
||||
vars?: Record<string, any>;
|
||||
className?: string;
|
||||
defaultText?: string;
|
||||
}
|
||||
|
||||
export const Translation: FC<TranslationProps> = ({
|
||||
translationKey,
|
||||
vars,
|
||||
className,
|
||||
defaultText,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let translatedText = t(translationKey, vars);
|
||||
|
||||
// Use fallback if translation is missing (returns the key itself)
|
||||
if (translatedText === translationKey && defaultText) {
|
||||
translatedText = defaultText;
|
||||
|
||||
// Interpolate variables manually into defaultText
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const [k, v] of Object.entries(vars || {})) {
|
||||
const regex = new RegExp(`{{\\s*${k}\\s*}}`, 'g');
|
||||
translatedText = translatedText.replace(regex, String(v));
|
||||
}
|
||||
}
|
||||
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: translatedText }} />;
|
||||
};
|
||||
|
||||
export default Translation;
|
||||
Reference in New Issue
Block a user