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:
Copilot
2025-07-13 02:21:46 -07:00
committed by GitHub
co-authored by gabek copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Owncast Gabe Kangas
parent 7b181aa0a6
commit 29b5100114
23 changed files with 2287 additions and 325 deletions
@@ -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 && (