Initial localization work (#3980)

* First pass at configuring localization

* Add CI job for translations

* Update CI job

* Update default value

* Update parser config

* Update defaults again

* try to fix the multiple parsing of a file

* Update crowdlin config

* Update configs

* New Crowdin translations by GitHub Action (#3448)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* Point to updated translated files

* Tooltip i18n

* Run translation job when web components are updated

* Commit updated translations

* Translations update (#3453)

* Update source file strings.json
Updated translations

* New translations strings.json (French)
Updated translations

* New translations strings.json (Spanish)
Updated translations

* New translations strings.json (German)
Updated translations

* New translations strings.json (English, United States)
Updated translations

* Commit updated translations

* New Crowdin translations by GitHub Action (#3452)

Co-authored-by: Owncast <owncast@owncast.online>

* chore(deps): update to next config to address build errors

* New Crowdin translations by GitHub Action (#3455)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* Translations update (#3456)

* New translations strings.json (Arabic)
Updated translations

* New translations strings.json (German)
Updated translations

* New translations strings.json (Greek)
Updated translations

* New translations strings.json (Irish)
Updated translations

* New translations strings.json (Italian)
Updated translations

* New translations strings.json (Japanese)
Updated translations

* New translations strings.json (Korean)
Updated translations

* New translations strings.json (Dutch)
Updated translations

* New translations strings.json (Norwegian)
Updated translations

* New translations strings.json (Punjabi)
Updated translations

* New translations strings.json (Russian)
Updated translations

* New translations strings.json (Swedish)
Updated translations

* New translations strings.json (Chinese Traditional)
Updated translations

* New translations strings.json (Vietnamese)
Updated translations

* New translations strings.json (Bengali)
Updated translations

* New translations strings.json (Thai)
Updated translations

* New translations strings.json (Croatian)
Updated translations

* New translations strings.json (Hindi)
Updated translations

* New translations strings.json (Malay)
Updated translations

* New Crowdin translations by GitHub Action (#3457)

* New translations strings.json (Arabic)
Updated translations

* New translations strings.json (German)
Updated translations

* New translations strings.json (Greek)
Updated translations

* New translations strings.json (Irish)
Updated translations

* New translations strings.json (Italian)
Updated translations

* New translations strings.json (Japanese)
Updated translations

* New translations strings.json (Korean)
Updated translations

* New translations strings.json (Dutch)
Updated translations

* New translations strings.json (Norwegian)
Updated translations

* New translations strings.json (Punjabi)
Updated translations

* New translations strings.json (Russian)
Updated translations

* New translations strings.json (Swedish)
Updated translations

* New translations strings.json (Chinese Traditional)
Updated translations

* New translations strings.json (Vietnamese)
Updated translations

* New translations strings.json (Bengali)
Updated translations

* New translations strings.json (Thai)
Updated translations

* New translations strings.json (Croatian)
Updated translations

* New translations strings.json (Hindi)
Updated translations

* New translations strings.json (Malay)
Updated translations

* New Crowdin translations by GitHub Action

---------

Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* Commit updated API documentation

* Update translations job

* New Crowdin translations by GitHub Action (#3698)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* Update Crowdin configuration file

* Translations update (#3700)

* New translations strings.json (French)
Updated translations

* New translations strings.json (Italian)
Updated translations

* Translations update (#3699)

* New translations strings.json (French)
Updated translations

* New translations strings.json (Spanish)
Updated translations

* New translations strings.json (Italian)
Updated translations

* New translations strings.json (Japanese)
Updated translations

* New translations strings.json (Polish)
Updated translations

* New translations strings.json (Russian)
Updated translations

* New translations strings.json (Portuguese, Brazilian)
Updated translations

* Commit updated API documentation

---------

Co-authored-by: Owncast <owncast@owncast.online>

* New Crowdin translations by GitHub Action (#3701)

* New translations strings.json (French)
Updated translations

* New translations strings.json (Spanish)
Updated translations

* New translations strings.json (Italian)
Updated translations

* New translations strings.json (Japanese)
Updated translations

* New translations strings.json (Polish)
Updated translations

* New translations strings.json (Russian)
Updated translations

* New translations strings.json (Portuguese, Brazilian)
Updated translations

* New Crowdin translations by GitHub Action

---------

Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* Draft: Mark strings for translation. (#3458)

* Mark strings for translation.

* Mark up strings for translation

* fix(web): fix linter warnings

---------

Co-authored-by: Le fractal <17422-fractal@users.noreply.framagit.org>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>

* do not pull from cowdin via workflow

* Commit updated translations

* feat: add translations support to admin pages and components (#3977)

* feat: add translations support to admin pages and components

Added translations support admin main page and its components, help
page, handware-info page. Added translations support for LogTable,
NewsFeed and StreamHealthOverview components.

* update package.json

* fix rendering issue

* Commit updated API documentation

---------

Co-authored-by: Owncast <owncast@owncast.online>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>

* Offline banner i18n formatting (#3997)

* Fix "Last live ago" string formatting with i18n interpolation

* Change some base translation jsons to use i18n interpolation

* Linting fix

* chore(js): ignore i18n pkgs in knip

* fix(test): fix browser ui test

* fix(js): remove unused var

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Owncast <owncast@owncast.online>
Co-authored-by: taintedcypher <119351153+taintedcypher@users.noreply.github.com>
Co-authored-by: Le fractal <17422-fractal@users.noreply.framagit.org>
Co-authored-by: Sufyaan Khateeb <81009832+SufyaanKhateeb@users.noreply.github.com>
Co-authored-by: mahmed2000 <mahmad2000@protonmail.com>
This commit is contained in:
Gabe Kangas
2025-01-14 20:54:21 -08:00
committed by GitHub
parent b45552ade0
commit cb387d88be
51 changed files with 6889 additions and 5026 deletions

55
.github/workflows/translations.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Translation job
on:
push:
paths:
- 'web/i18n/strings.json'
- 'web/**/*.tsx'
- 'web/**/*.js'
- 'crowdin.yml'
- '.github/workflows/translations.yml'
- 'web/i18next-parser.config.mjs'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
generate-translations:
defaults:
run:
working-directory: ./web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }}
run: npm install
- name: Generate translation files
run: npm run translate
- name: Crowdin upload sources/download translations
uses: crowdin/github-action@v1
with:
upload_sources: true
download_translations: false
localization_branch_name: translations
config: crowdin.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: 'Commit updated translations'
add: 'web/i18n/**'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

10
crowdin.yml Normal file
View File

@ -0,0 +1,10 @@
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
pull_request_title: Translations update
pull_request_labels:
- crowdin
- i18n
commit_message: Updated translations
files:
- source: /web/i18n/strings.json
translation: /web/i18n/%two_letters_code%.json

File diff suppressed because one or more lines are too long

View File

@ -1,10 +1,10 @@
// TODO: Fire API call to enable federation and set domain, and stream username.
import fetchData from '../../support/fetchData.js'; import fetchData from '../../support/fetchData.js';
import { setup } from '../../support/setup.js';
setup();
describe('Fediverse tests', () => { describe('Fediverse tests', () => {
// Enable Fediverse features. // Enable Fediverse features.
before(() => { it('Can visit the page', () => {
fetchData('http://localhost:8080/api/admin/config/serverurl', { fetchData('http://localhost:8080/api/admin/config/serverurl', {
method: 'POST', method: 'POST',
data: { value: 'https://testing.biz' }, data: { value: 'https://testing.biz' },
@ -13,11 +13,10 @@ describe('Fediverse tests', () => {
method: 'POST', method: 'POST',
data: { value: true }, data: { value: true },
}); });
}); cy.wait(1500);
it('Can visit the page', () => {
cy.visit('http://localhost:8080/'); cy.visit('http://localhost:8080/');
cy.reload(true); // cy.reload(true, { timeout: 10000 });
}); });
// Offline banner // Offline banner

View File

@ -16,10 +16,22 @@
"ignoreDependencies": [ "ignoreDependencies": [
"@fontsource/inter", "@fontsource/inter",
"@fontsource/poppins", "@fontsource/poppins",
"@next/bundle-analyzer",
"autoprefixer",
"yaml", "yaml",
"sharp", "sharp",
"workbox-precaching", "workbox-precaching",
"workbox-window", "workbox-window",
"@storybook/addon-a11y",
"@storybook/addon-actions",
"@storybook/addon-docs",
"@storybook/addon-essentials",
"@storybook/addon-links",
"@storybook/addon-postcss",
"@storybook/addon-viewport",
"@storybook/cli",
"@storybook/mdx2-csf",
"@storybook/preset-scss",
"@mdx-js/react", "@mdx-js/react",
"@storybook/testing-library", "@storybook/testing-library",
"@svgr/webpack", "@svgr/webpack",
@ -34,6 +46,9 @@
"install", "install",
"mdx-mermaid", "mdx-mermaid",
"mermaid", "mermaid",
"sass-loader",
"sb",
"storybook-addon-fetch-mock",
"storybook-preset-less", "storybook-preset-less",
"ts-jest", "ts-jest",
"stylelint-config-standard", "stylelint-config-standard",
@ -46,6 +61,16 @@
"@commitlint/cli", "@commitlint/cli",
"@commitlint/config-conventional", "@commitlint/config-conventional",
"babel-plugin-dynamic-import-node", "babel-plugin-dynamic-import-node",
"@babel/preset-react" "@babel/preset-react",
"postcss",
"stylelint",
"@babel/core",
"@storybook/addon-mdx-gfm",
"@storybook/addon-styling-webpack",
"@storybook/nextjs",
"@storybook/react",
"resolve-url-loader",
"i18next-parser",
"i18next-scanner"
] ]
} }

View File

@ -1,6 +1,7 @@
import { Button } from 'antd'; import { Button } from 'antd';
import { FC } from 'react'; import { FC } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import styles from './ActionButton/ActionButton.module.scss'; import styles from './ActionButton/ActionButton.module.scss';
// Lazy loaded components // Lazy loaded components
@ -14,7 +15,10 @@ export type NotifyButtonProps = {
onClick?: () => void; onClick?: () => void;
}; };
export const NotifyButton: FC<NotifyButtonProps> = ({ onClick, text }) => ( export const NotifyButton: FC<NotifyButtonProps> = ({ onClick, text }) => {
const { t } = useTranslation();
return (
<Button <Button
type="primary" type="primary"
className={styles.button} className={styles.button}
@ -22,6 +26,7 @@ export const NotifyButton: FC<NotifyButtonProps> = ({ onClick, text }) => (
onClick={onClick} onClick={onClick}
id="notify-button" id="notify-button"
> >
{text || 'Notify'} {text || t('Notify')}
</Button> </Button>
); );
};

View File

@ -3,6 +3,7 @@ import { Table, Tag, Typography } from 'antd';
import Linkify from 'react-linkify'; import Linkify from 'react-linkify';
import { SortOrder, TablePaginationConfig } from 'antd/lib/table/interface'; import { SortOrder, TablePaginationConfig } from 'antd/lib/table/interface';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useTranslation } from 'next-export-i18n';
const { Title } = Typography; const { Title } = Typography;
@ -28,39 +29,41 @@ export type LogTableProps = {
}; };
export const LogTable: FC<LogTableProps> = ({ logs, initialPageSize }) => { export const LogTable: FC<LogTableProps> = ({ logs, initialPageSize }) => {
if (!logs?.length) { const { t } = useTranslation();
return null;
}
const [pageSize, setPageSize] = useState(initialPageSize); const [pageSize, setPageSize] = useState(initialPageSize);
const handleTableChange = (pagination: TablePaginationConfig) => { const handleTableChange = (pagination: TablePaginationConfig) => {
setPageSize(pagination.pageSize); setPageSize(pagination.pageSize);
}; };
if (!logs?.length) {
return null;
}
const columns = [ const columns = [
{ {
title: 'Level', title: t('Level'),
dataIndex: 'level', dataIndex: 'level',
key: 'level', key: 'level',
filters: [ filters: [
{ {
text: 'Info', text: t('Info'),
value: 'info', value: 'info',
}, },
{ {
text: 'Warning', text: t('Warning'),
value: 'warning', value: 'warning',
}, },
{ {
text: 'Error', text: t('Error'),
value: 'error', value: 'Error',
}, },
], ],
onFilter: (level, row) => row.level.indexOf(level) === 0, onFilter: (level, row) => row.level.indexOf(level) === 0,
render: renderColumnLevel, render: renderColumnLevel,
}, },
{ {
title: 'Timestamp', title: t('Timestamp'),
dataIndex: 'time', dataIndex: 'time',
key: 'time', key: 'time',
render: timestamp => { render: timestamp => {
@ -72,7 +75,7 @@ export const LogTable: FC<LogTableProps> = ({ logs, initialPageSize }) => {
defaultSortOrder: 'descend' as SortOrder, defaultSortOrder: 'descend' as SortOrder,
}, },
{ {
title: 'Message', title: t('Message'),
dataIndex: 'message', dataIndex: 'message',
key: 'message', key: 'message',
render: renderMessage, render: renderMessage,
@ -81,7 +84,7 @@ export const LogTable: FC<LogTableProps> = ({ logs, initialPageSize }) => {
return ( return (
<div className="logs-section"> <div className="logs-section">
<Title>Logs</Title> <Title>{t('Logs')}</Title>
<Table <Table
size="middle" size="middle"
dataSource={logs} dataSource={logs}

View File

@ -4,6 +4,7 @@ import React, { useState, useEffect, FC } from 'react';
import { Collapse, Typography, Skeleton } from 'antd'; import { Collapse, Typography, Skeleton } from 'antd';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useTranslation } from 'next-export-i18n';
import { fetchExternalData } from '../../utils/apis'; import { fetchExternalData } from '../../utils/apis';
const { Panel } = Collapse; const { Panel } = Collapse;
@ -27,6 +28,7 @@ const ArticleItem: FC<ArticleProps> = ({
date_published: date, date_published: date,
defaultOpen = false, defaultOpen = false,
}) => { }) => {
const { t } = useTranslation();
const dateObject = new Date(date); const dateObject = new Date(date);
const dateString = format(dateObject, 'MMM dd, yyyy, HH:mm'); const dateString = format(dateObject, 'MMM dd, yyyy, HH:mm');
return ( return (
@ -36,7 +38,7 @@ const ArticleItem: FC<ArticleProps> = ({
<p className="timestamp"> <p className="timestamp">
{dateString} ( {dateString} (
<Link href={`${OWNCAST_BASE_URL}${url}`} target="_blank" rel="noopener noreferrer"> <Link href={`${OWNCAST_BASE_URL}${url}`} target="_blank" rel="noopener noreferrer">
Link {t('Link')}
</Link> </Link>
) )
</p> </p>
@ -48,6 +50,7 @@ const ArticleItem: FC<ArticleProps> = ({
}; };
export const NewsFeed = () => { export const NewsFeed = () => {
const { t } = useTranslation();
const [feed, setFeed] = useState<ArticleProps[]>([]); const [feed, setFeed] = useState<ArticleProps[]>([]);
const [loading, setLoading] = useState<Boolean>(true); const [loading, setLoading] = useState<Boolean>(true);
@ -69,11 +72,11 @@ export const NewsFeed = () => {
}, []); }, []);
const loadingSpinner = loading ? <Skeleton loading active /> : null; const loadingSpinner = loading ? <Skeleton loading active /> : null;
const noNews = !loading && feed.length === 0 ? <div>No news.</div> : null; const noNews = !loading && feed.length === 0 ? <div>{t('No news.')}</div> : null;
return ( return (
<section className="news-feed form-module"> <section className="news-feed form-module">
<Title level={2}>News &amp; Updates from Owncast</Title> <Title level={2}>{t('News & Updates from Owncast')}</Title>
{loadingSpinner} {loadingSpinner}
{feed.map(item => ( {feed.map(item => (
<ArticleItem {...item} key={item.url} defaultOpen={feed.length === 1} /> <ArticleItem {...item} key={item.url} defaultOpen={feed.length === 1} />

View File

@ -1,3 +1,4 @@
import { useTranslation } from 'next-export-i18n';
import { Card, Col, Row, Typography } from 'antd'; import { Card, Col, Row, Typography } from 'antd';
import Link from 'next/link'; import Link from 'next/link';
import { FC, useContext } from 'react'; import { FC, useContext } from 'react';
@ -43,6 +44,7 @@ export type OfflineProps = {
export const Offline: FC<OfflineProps> = ({ logs = [], config }) => { export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
const serverStatusData = useContext(ServerStatusContext); const serverStatusData = useContext(ServerStatusContext);
const { t } = useTranslation();
const { serverConfig } = serverStatusData || {}; const { serverConfig } = serverStatusData || {};
const { rtmpServerPort, streamKeyOverridden } = serverConfig; const { rtmpServerPort, streamKeyOverridden } = serverConfig;
const instanceUrl = global.window?.location.hostname || ''; const instanceUrl = global.window?.location.hostname || '';
@ -55,7 +57,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
const data = [ const data = [
{ {
icon: <BookTwoTone twoToneColor="#6f42c1" />, icon: <BookTwoTone twoToneColor="#6f42c1" />,
title: 'Use your broadcasting software', title: t('Use your broadcasting software'),
content: ( content: (
<div> <div>
<a <a
@ -63,12 +65,13 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Learn how to point your existing software to your new server and start streaming your {t(
content. 'Learn how to point your existing software to your new server and start streaming your content.',
)}
</a> </a>
<div className="stream-info-container"> <div className="stream-info-container">
<Text strong className="stream-info-label"> <Text strong className="stream-info-label">
Streaming URL: {t('Streaming URL:')}
</Text> </Text>
{rtmpURL && ( {rtmpURL && (
<Paragraph className="stream-info-box" copyable> <Paragraph className="stream-info-box" copyable>
@ -76,14 +79,14 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
</Paragraph> </Paragraph>
)} )}
<Text strong className="stream-info-label"> <Text strong className="stream-info-label">
Streaming Keys: {t('Streaming Keys:')}
</Text> </Text>
<Text strong className="stream-info-box"> <Text strong className="stream-info-box">
{!streamKeyOverridden ? ( {!streamKeyOverridden ? (
<Link href="/admin/config/server"> View </Link> <Link href="/admin/config/server"> {t('View')} </Link>
) : ( ) : (
<span style={{ paddingLeft: '10px', fontWeight: 'normal' }}> <span style={{ paddingLeft: '10px', fontWeight: 'normal' }}>
Overridden via command line. {t('Overridden via command line.')}
</span> </span>
)} )}
</Text> </Text>
@ -93,7 +96,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
}, },
{ {
icon: <PlaySquareTwoTone twoToneColor="#f9826c" />, icon: <PlaySquareTwoTone twoToneColor="#f9826c" />,
title: 'Embed your video onto other sites', title: t('Embed your video onto other sites'),
content: ( content: (
<div> <div>
<a <a
@ -101,7 +104,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Learn how you can add your Owncast stream to other sites you control. {t('Learn how you can add your Owncast stream to other sites you control.')}
</a> </a>
</div> </div>
), ),
@ -111,19 +114,19 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
if (!config?.chatDisabled) { if (!config?.chatDisabled) {
data.push({ data.push({
icon: <MessageTwoTone twoToneColor="#0366d6" />, icon: <MessageTwoTone twoToneColor="#0366d6" />,
title: 'Chat is disabled', title: t('Chat is disabled'),
content: <span>Chat will continue to be disabled until you begin a live stream.</span>, content: <span>{t('Chat will continue to be disabled until you begin a live stream.')}</span>,
}); });
} }
if (!config?.yp?.enabled) { if (!config?.yp?.enabled) {
data.push({ data.push({
icon: <ProfileTwoTone twoToneColor="#D18BFE" />, icon: <ProfileTwoTone twoToneColor="#D18BFE" />,
title: 'Find an audience on the Owncast Directory', title: t('Find an audience on the Owncast Directory'),
content: ( content: (
<div> <div>
List yourself in the Owncast Directory and show off your stream. Enable it in{' '} {t('List yourself in the Owncast Directory and show off your stream. Enable it in')}{' '}
<Link href="/admin/config/general/">settings.</Link> <Link href="/admin/config/general/">{t('settings.')}</Link>
</div> </div>
), ),
}); });
@ -132,12 +135,13 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
if (!config?.federation?.enabled) { if (!config?.federation?.enabled) {
data.push({ data.push({
icon: <img alt="fediverse" width="20px" src="/img/fediverse-color.png" />, icon: <img alt="fediverse" width="20px" src="/img/fediverse-color.png" />,
title: 'Add your Owncast instance to the Fediverse', title: t('Add your Owncast instance to the Fediverse'),
content: ( content: (
<div> <div>
<Link href="/admin/config-federation/">Enable Owncast social</Link> features to have your <Link href="/admin/config-federation/">{t('Enable Owncast social features')}</Link>{' '}
instance join the Fediverse, allowing people to follow, share and engage with your live {t(
stream. 'to have your instance join the Fediverse, allowing people to follow, share and engage with your live stream.',
)}
</div> </div>
), ),
}); });
@ -152,8 +156,8 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
<OwncastLogo variant="simple" /> <OwncastLogo variant="simple" />
</span> </span>
<div> <div>
<Title level={2}>No stream is active</Title> <Title level={2}>{t('No stream is active')}</Title>
<p>You should start one.</p> <p>{t('You should start one.')}</p>
</div> </div>
</div> </div>
</Col> </Col>

View File

@ -2,6 +2,7 @@ import { Alert, Button, Card, Col, Row, Statistic, Typography } from 'antd';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import Link from 'next/link'; import Link from 'next/link';
import React, { FC, useContext } from 'react'; import React, { FC, useContext } from 'react';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../utils/server-status-context'; import { ServerStatusContext } from '../../utils/server-status-context';
// Lazy loaded components // Lazy loaded components
@ -22,6 +23,7 @@ export type StreamHealthOverviewProps = {
}; };
export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubleshootButton }) => { export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubleshootButton }) => {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext); const serverStatusData = useContext(ServerStatusContext);
const { health } = serverStatusData; const { health } = serverStatusData;
if (!health) { if (!health) {
@ -45,15 +47,15 @@ export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubl
<Row gutter={8}> <Row gutter={8}>
<Col span={12}> <Col span={12}>
<Statistic <Statistic
title="Healthy Stream" title={t('Healthy Stream')}
value={healthy ? 'Yes' : 'No'} value={healthy ? t('Yes') : t('No')}
valueStyle={{ color }} valueStyle={{ color }}
prefix={healthy ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />} prefix={healthy ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
/> />
</Col> </Col>
<Col span={12}> <Col span={12}>
<Statistic <Statistic
title="Playback Health" title={t('Playback Health')}
value={healthPercentage} value={healthPercentage}
valueStyle={{ color }} valueStyle={{ color }}
suffix="%" suffix="%"
@ -65,8 +67,7 @@ export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubl
type="secondary" type="secondary"
style={{ textAlign: 'center', fontSize: '0.7em', opacity: '0.3' }} style={{ textAlign: 'center', fontSize: '0.7em', opacity: '0.3' }}
> >
Stream health represents {representation}% of all known players. Other player status is {`${t('Stream health represents')} ${representation}% ${t('of all known players. Other player status is unknown.')}`}
unknown.
</Typography.Text> </Typography.Text>
</Row> </Row>
<Row <Row
@ -82,7 +83,7 @@ export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubl
showTroubleshootButton && ( showTroubleshootButton && (
<Link passHref href="/admin/stream-health"> <Link passHref href="/admin/stream-health">
<Button size="small" type="text" style={{ color: 'black' }}> <Button size="small" type="text" style={{ color: 'black' }}>
TROUBLESHOOT {t('TROUBLESHOOT')}
</Button> </Button>
</Link> </Link>
) )

View File

@ -1,5 +1,6 @@
import { FC } from 'react'; import { FC } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useTranslation } from 'next-export-i18n';
import styles from './Footer.module.scss'; import styles from './Footer.module.scss';
import { ServerStatus } from '../../../interfaces/server-status.model'; import { ServerStatus } from '../../../interfaces/server-status.model';
import { serverStatusState } from '../../stores/ClientConfigStore'; import { serverStatusState } from '../../stores/ClientConfigStore';
@ -7,20 +8,21 @@ import { serverStatusState } from '../../stores/ClientConfigStore';
export const Footer: FC = () => { export const Footer: FC = () => {
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState); const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
const { versionNumber } = clientStatus; const { versionNumber } = clientStatus;
const { t } = useTranslation();
return ( return (
<footer className={styles.footer} id="footer"> <footer className={styles.footer} id="footer">
<span> <span>
Powered by <a href="https://owncast.online">Owncast v{versionNumber}</a> {t('Powered by Owncast')} <a href="https://owncast.online">v{versionNumber}</a>
</span> </span>
<span className={styles.links}> <span className={styles.links}>
<a href="https://owncast.online/docs" target="_blank" rel="noreferrer"> <a href="https://owncast.online/docs" target="_blank" rel="noreferrer">
Documentation {t('Documentation')}
</a> </a>
<a href="https://owncast.online/help" target="_blank" rel="noreferrer"> <a href="https://owncast.online/help" target="_blank" rel="noreferrer">
Contribute {t('Contribute')}
</a> </a>
<a href="https://github.com/owncast/owncast" target="_blank" rel="noreferrer"> <a href="https://github.com/owncast/owncast" target="_blank" rel="noreferrer">
Source {t('Source')}
</a> </a>
</span> </span>
</footer> </footer>

View File

@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import cn from 'classnames'; import cn from 'classnames';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'next-export-i18n';
import styles from './Header.module.scss'; import styles from './Header.module.scss';
// Lazy loaded components // Lazy loaded components
@ -23,6 +24,7 @@ export type HeaderComponentProps = {
export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => { export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => {
const [canHideChat, setCanHideChat] = useState(false); const [canHideChat, setCanHideChat] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setCanHideChat(window.innerWidth >= 768); setCanHideChat(window.innerWidth >= 768);
@ -32,18 +34,18 @@ export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisa
<header className={cn([`${styles.header}`], 'global-header')}> <header className={cn([`${styles.header}`], 'global-header')}>
{online ? ( {online ? (
<Link href="#player" className={styles.skipLink}> <Link href="#player" className={styles.skipLink}>
Skip to player {t('Skip to player')}
</Link> </Link>
) : ( ) : (
<Link href="#offline-message" className={styles.skipLink}> <Link href="#offline-message" className={styles.skipLink}>
Skip to offline message {t('Skip to offline message')}
</Link> </Link>
)} )}
<Link href="#skip-to-content" className={styles.skipLink}> <Link href="#skip-to-content" className={styles.skipLink}>
Skip to page content {t('Skip to page content')}
</Link> </Link>
<Link href="#footer" className={styles.skipLink}> <Link href="#footer" className={styles.skipLink}>
Skip to footer {t('Skip to footer')}
</Link> </Link>
<div className={styles.logo}> <div className={styles.logo}>
<div id="header-logo" className={styles.logoImage}> <div id="header-logo" className={styles.logoImage}>
@ -59,10 +61,10 @@ export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisa
{!chatAvailable && !chatDisabled && ( {!chatAvailable && !chatDisabled && (
<Tooltip <Tooltip
overlayClassName={styles.toolTip} overlayClassName={styles.toolTip}
title="Chat will be available when the stream is live." title={t('Chat will be available when the stream is live.')}
placement="left" placement="left"
> >
<span className={styles.chatOfflineText}>Chat is offline</span> <span className={styles.chatOfflineText}>{t('Chat is offline')}</span>
</Tooltip> </Tooltip>
)} )}
</header> </header>

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, FC } from 'react'; import React, { useState, useEffect, FC } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import styles from './NotifyReminderPopup.module.scss'; import styles from './NotifyReminderPopup.module.scss';
import { Popover } from '../Popover/Popover'; import { Popover } from '../Popover/Popover';
@ -24,6 +25,7 @@ export const NotifyReminderPopup: FC<NotifyReminderPopupProps> = ({
}) => { }) => {
const [openPopup, setOpenPopup] = useState(open); const [openPopup, setOpenPopup] = useState(open);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setOpenPopup(open); setOpenPopup(open);
@ -33,7 +35,7 @@ export const NotifyReminderPopup: FC<NotifyReminderPopupProps> = ({
setMounted(true); setMounted(true);
}, []); }, []);
const title = <div className={styles.title}>Stay updated!</div>; const title = <div className={styles.title}>{t('Stay updated!')}</div>;
const popupClicked = e => { const popupClicked = e => {
e.stopPropagation(); e.stopPropagation();
@ -56,7 +58,7 @@ export const NotifyReminderPopup: FC<NotifyReminderPopupProps> = ({
> >
<CloseOutlined /> <CloseOutlined />
</button> </button>
<div className={styles.contentbutton}>Click and never miss future streams!</div> <div className={styles.contentbutton}>{t('Click and never miss future streams!')}</div>
</div> </div>
); );

View File

@ -5,6 +5,7 @@ import { FC } from 'react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'next-export-i18n';
import styles from './OfflineBanner.module.scss'; import styles from './OfflineBanner.module.scss';
// Lazy loaded components // Lazy loaded components
@ -36,13 +37,15 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
onFollowClick, onFollowClick,
className, className,
}) => { }) => {
const { t } = useTranslation();
let text; let text;
if (customText) { if (customText) {
text = customText; text = customText;
} else if (!customText && notificationsEnabled && fediverseAccount) { } else if (!customText && notificationsEnabled && fediverseAccount) {
text = ( text = (
<span> <span>
This stream is offline. You can{' '} {t('This stream is offline. You can')}{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}> <span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
be notified be notified
</span>{' '} </span>{' '}
@ -56,21 +59,25 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
} else if (!customText && notificationsEnabled) { } else if (!customText && notificationsEnabled) {
text = ( text = (
<span> <span>
This stream is offline.{' '} {t('This stream is offline')}.{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}> <span role="link" tabIndex={0} className={styles.actionLink} onClick={onNotifyClick}>
Be notified Be notified
</span>{' '} </span>{' '}
the next time {streamName} goes live. {t('the next time goes live', { streamer: streamName })}.
</span> </span>
); );
} else if (!customText && fediverseAccount) { } else if (!customText && fediverseAccount) {
text = ( text = (
<span> <span>
This stream is offline.{' '} {t('This stream is offline.')}{' '}
<span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}> <span role="link" tabIndex={0} className={styles.actionLink} onClick={onFollowClick}>
Follow {t('Follow')}
</span>{' '} </span>{' '}
{fediverseAccount} on the Fediverse to see the next time {streamName} goes live. {t('on the Fediverse to see the next time goes live', {
fediverseAccount,
streamer: streamName,
})}
.
</span> </span>
); );
} else { } else {
@ -95,7 +102,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
{lastLive && ( {lastLive && (
<div className={styles.lastLiveDate}> <div className={styles.lastLiveDate}>
<ClockCircleOutlined className={styles.clockIcon} /> <ClockCircleOutlined className={styles.clockIcon} />
{`Last live ${formatDistanceToNow(new Date(lastLive))} ago.`} {`${t('Last live ago', { timeAgo: formatDistanceToNow(new Date(lastLive)) })}`}
</div> </div>
)} )}
</div> </div>

20
web/i18n/ar.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/bn.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/de.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Benachrichtigen",
"Powered by Owncast": "Betrieben von Owncast",
"Documentation": "Dokumentation",
"Contribute": "Beitragen",
"Source": "Quelle",
"Skip to player": "Zum Spieler springen",
"Skip to offline message": "Zur Offline-Nachricht springen",
"Skip to page content": "Direkt zum Hauptinhalt",
"Skip to footer": "Zum Footer springen",
"Chat will be available when the stream is live": "Chat ist verfügbar, wenn der Stream live ist.",
"Chat is offline": "Chat ist offline",
"Stay updated!": "Bleiben Sie auf dem Laufenden Ihrer Tätigkeiten!",
"Click and never miss future streams!": "Klicke und verpasse niemals zukünftige Streams!",
"This stream is offline": "Dieser Stream ist offline.",
"the next time goes live": "das nächste Mal geht live",
"Follow": "Folgen",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Zuletzt live"
}

20
web/i18n/el.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/en.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/es.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notificar",
"Powered by Owncast": "Desarrollado por Owncast",
"Documentation": "Documentación",
"Contribute": "Contribuir",
"Source": "Fuente",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/fr.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Avertir",
"Powered by Owncast": "Propulsé par Owncast",
"Documentation": "Documentation",
"Contribute": "Contribuer",
"Source": "Source",
"Skip to player": "Passer au joueur",
"Skip to offline message": "Aller au message d'absence",
"Skip to page content": "Aller au contenu principal",
"Skip to footer": "Aller au pied de page",
"Chat will be available when the stream is live": "Le chat sera disponible quand le direct débutera.",
"Chat is offline": "Le chat est hors ligne",
"Stay updated!": "Restez à jour !",
"Click and never miss future streams!": "Cliquez et ne manquez jamais les futurs diffusions !",
"This stream is offline": "Ce serveur est hors-ligne.",
"the next time goes live": "la prochaine fois que diffuse en direct",
"Follow": "Suivre",
"on the Fediverse to see the next time goes live": "sur le Fédiverse pour voir la prochaine fois que lance un direct",
"Last live ago": "Dernière diffusion il y a"
}

20
web/i18n/ga.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/hi.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/hr.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

19
web/i18n/index.js Normal file
View File

@ -0,0 +1,19 @@
const en = require('./en.json');
const es = require('./es.json');
const de = require('./de.json');
const fr = require('./fr.json');
const i18n = {
translations: {
en,
es,
de,
fr,
},
defaultLang: 'en',
useBrowserDefault: true,
// optional property, will default to "query" if not set
languageDataStore: 'query' || 'localStorage',
};
module.exports = i18n;

20
web/i18n/it.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notifica",
"Powered by Owncast": "Alimentato da Owncast",
"Documentation": "Documentazione",
"Contribute": "Contribuisci",
"Source": "Fonte",
"Skip to player": "Vai al lettore video",
"Skip to offline message": "Vai al messaggio offline",
"Skip to page content": "Vai al contenuto della pagina",
"Skip to footer": "Vai a piè di pagina",
"Chat will be available when the stream is live": "La chat sarà disponibile quando lo stream è in diretta.",
"Chat is offline": "La chat è offline",
"Stay updated!": "Rimani aggiornato!",
"Click and never miss future streams!": "Clicca e non perderti mai gli stream futuri!",
"This stream is offline": "Questo stream è offline.",
"the next time goes live": "la prossima volta che andrà in diretta",
"Follow": "Segui",
"on the Fediverse to see the next time goes live": "sul Fediverso per vedere la prossima volta che andrà in diretta",
"Last live ago": "Ultima diretta fa"
}

20
web/i18n/ja.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "通知",
"Powered by Owncast": "Owncastによる提供",
"Documentation": "ドキュメンテーション",
"Contribute": "貢献",
"Source": "ソース",
"Skip to player": "プレイヤーへスキップ",
"Skip to offline message": "オフラインメッセージへスキップ",
"Skip to page content": "メインコンテンツへスキップ",
"Skip to footer": "フッターへスキップ",
"Chat will be available when the stream is live": "チャットは配信が始まると利用できます",
"Chat is offline": "チャットはオフラインです",
"Stay updated!": "通知を受け取るようにしましょう",
"Click and never miss future streams!": "配信を見逃さないようにクリックしましょう",
"This stream is offline": "配信はオフラインです",
"the next time goes live": "次の配信で",
"Follow": "フォロー",
"on the Fediverse to see the next time goes live": "Fediverseで次回のライブを見るために",
"Last live ago": "前回の配信は 前でした"
}

20
web/i18n/ko.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/ms.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/nl.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/no.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/pa.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/pl.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/pt.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notificar",
"Powered by Owncast": "Desenvolvido por Owncast",
"Documentation": "Documentação",
"Contribute": "Contribua",
"Source": "Fonte",
"Skip to player": "Pular para o player",
"Skip to offline message": "Pular para mensagem offline",
"Skip to page content": "Pular para página de conteúdo",
"Skip to footer": "Pular para o rodapé",
"Chat will be available when the stream is live": "O Chat estará disponível quando a transmissão estiver ativa.",
"Chat is offline": "O chat está off-line",
"Stay updated!": "Mantenha-se atualizado!",
"Click and never miss future streams!": "Clique e não perca futuras transmissões!",
"This stream is offline": "Esta transmissão não está ativa.",
"the next time goes live": "a próxima transmissão será",
"Follow": "Siga",
"on the Fediverse to see the next time goes live": "no Fediverse para ver a data da próxima transmissão",
"Last live ago": "Última transmissão foi"
}

20
web/i18n/ru.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Оповещать",
"Powered by Owncast": "Работает на Owncast",
"Documentation": "Документация",
"Contribute": "Внести вклад",
"Source": "Исходный код",
"Skip to player": "Перейти к плееру",
"Skip to offline message": "Перейти к сообщениям офлайн",
"Skip to page content": "Перейти к основному содержимому",
"Skip to footer": "Перейти к нижнему колонтитулу",
"Chat will be available when the stream is live": "Чат будет доступен, когда будет запущен прямой эфир.",
"Chat is offline": "Чат не в сети",
"Stay updated!": "Будьте в курсе!",
"Click and never miss future streams!": "Кликните и никогда не пропустите будущие стримы!",
"This stream is offline": "Эта трансляция выключена.",
"the next time goes live": "в следующий раз, когда выйдет в эфир",
"Follow": "Подписаться",
"on the Fediverse to see the next time goes live": "на Fedivers, чтобы посмотреть, когда в следующий раз выйдет в прямой эфир",
"Last live ago": "Последний эфир назад"
}

1
web/i18n/strings.json Normal file
View File

@ -0,0 +1 @@
{}

20
web/i18n/strings_old.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/sv.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/th.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/vi.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

20
web/i18n/zh.json Normal file
View File

@ -0,0 +1,20 @@
{
"Notify": "Notify",
"Powered by Owncast": "Powered by Owncast",
"Documentation": "Documentation",
"Contribute": "Contribute",
"Source": "Source",
"Skip to player": "Skip to player",
"Skip to offline message": "Skip to offline message",
"Skip to page content": "Skip to page content",
"Skip to footer": "Skip to footer",
"Chat will be available when the stream is live": "Chat will be available when the stream is live.",
"Chat is offline": "Chat is offline",
"Stay updated!": "Stay updated!",
"Click and never miss future streams!": "Click and never miss future streams!",
"This stream is offline": "This stream is offline.",
"the next time goes live": "the next time {{streamer}} goes live",
"Follow": "Follow",
"on the Fediverse to see the next time goes live": "on the Fediverse to see the next time goes live",
"Last live ago": "Last live {{timeAgo}} ago"
}

View File

@ -0,0 +1,113 @@
// i18next-parser.config.js
export default {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: function (locale, namespace, key, value) {
return `${key}`;
}, // Default value to give to keys with no value
// You may also specify a function accepting the locale, namespace, key, and value as arguments
indentation: 2,
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
// You may either specify a boolean to keep or discard all removed keys.
// You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept.
// The patterns are applied to the full key including the namespace, the parent keys and the separators.
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
hbs: ['HandlebarsLexer'],
handlebars: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
mjs: ['JavascriptLexer'],
js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JavascriptLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: ':',
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'i18n/strings.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
// If you don't want to generate keys for plurals (for example, in case you are using ICU format), set `pluralSeparator: false`.
input: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: false,
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
verbose: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", // t('my-key', {maxLength: 150})
// }
resetDefaultValueLocale: null,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
// i18nextOptions: { returnDetails: true, lng: '$LOCALE' },
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
yamlOptions: null,
// If you wish to customize options for yaml output, you can define an object here.
// Configuration options are here (https://github.com/nodeca/js-yaml#dump-object---options-).
// Example:
// {
// lineWidth: -1,
// }
};

1742
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,8 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"build-styles": "cd ./style-definitions && style-dictionary build && ./build.sh && cd -", "build-styles": "cd ./style-definitions && style-dictionary build && ./build.sh && cd -",
"test": "jest", "test": "jest",
"format": "prettier --write **/*.{js,ts,jsx,tsx,css,md,scss}" "format": "prettier --write **/*.{js,ts,jsx,tsx,css,md,scss}",
"translate": "i18next -c i18next-parser.config.mjs"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "4.8.3", "@ant-design/icons": "4.8.3",
@ -39,10 +40,13 @@
"classnames": "2.5.1", "classnames": "2.5.1",
"date-fns": "^4.0.0", "date-fns": "^4.0.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"i18next-parser": "^9.1.0",
"i18next-scanner": "^4.6.0",
"interweave": "^13.0.0", "interweave": "^13.0.0",
"interweave-autolink": "^5.1.0", "interweave-autolink": "^5.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"next": "14.2.21", "next": "14.2.21",
"next-export-i18n": "^3.0.0",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-with-less": "3.0.1", "next-with-less": "3.0.1",
"postcss-flexbugs-fixes": "5.0.2", "postcss-flexbugs-fixes": "5.0.2",

View File

@ -24,7 +24,7 @@ class InlineStylesHead extends Head {
export default function Document() { export default function Document() {
return ( return (
<Html lang="en"> <Html>
<InlineStylesHead /> <InlineStylesHead />
<body> <body>
<Main /> <Main />

View File

@ -5,6 +5,7 @@ import { ColumnsType } from 'antd/es/table';
import { format } from 'date-fns'; import { format } from 'date-fns';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { MessageType } from '../../../types/chat'; import { MessageType } from '../../../types/chat';
import { import {
CHAT_HISTORY, CHAT_HISTORY,
@ -61,6 +62,7 @@ export default function Chat() {
const [bulkProcessing, setBulkProcessing] = useState(false); const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkOutcome, setBulkOutcome] = useState(null); const [bulkOutcome, setBulkOutcome] = useState(null);
const [bulkAction, setBulkAction] = useState(''); const [bulkAction, setBulkAction] = useState('');
const { t } = useTranslation();
let outcomeTimeout = null; let outcomeTimeout = null;
let chatReloadInterval = null; let chatReloadInterval = null;
@ -153,7 +155,7 @@ export default function Chat() {
const chatColumns: ColumnsType<MessageType> = [ const chatColumns: ColumnsType<MessageType> = [
{ {
title: 'Time', title: t('Time'),
dataIndex: 'timestamp', dataIndex: 'timestamp',
key: 'timestamp', key: 'timestamp',
className: 'timestamp-col', className: 'timestamp-col',
@ -166,7 +168,7 @@ export default function Chat() {
width: 90, width: 90,
}, },
{ {
title: 'User', title: t('User'),
dataIndex: 'user', dataIndex: 'user',
key: 'user', key: 'user',
className: 'name-col', className: 'name-col',
@ -182,7 +184,7 @@ export default function Chat() {
width: 110, width: 110,
}, },
{ {
title: 'Message', title: t('Message'),
dataIndex: 'body', dataIndex: 'body',
key: 'body', key: 'body',
className: 'message-col', className: 'message-col',
@ -201,8 +203,8 @@ export default function Chat() {
key: 'hiddenAt', key: 'hiddenAt',
className: 'toggle-col', className: 'toggle-col',
filters: [ filters: [
{ text: 'Visible messages', value: true }, { text: t('Visible messages'), value: true },
{ text: 'Hidden messages', value: false }, { text: t('Hidden messages'), value: false },
], ],
onFilter: (value, record) => record.visible === value, onFilter: (value, record) => record.visible === value,
render: (hiddenAt, record) => ( render: (hiddenAt, record) => (
@ -219,10 +221,12 @@ export default function Chat() {
return ( return (
<div className="chat-messages"> <div className="chat-messages">
<Title>Chat Messages</Title> <Title>{t('Chat Messages')}</Title>
<p>Manage the messages from viewers that show up on your stream.</p> <p>{t('Manage the messages from viewers that show up on your stream.')}</p>
<div className={bulkDivClasses}> <div className={bulkDivClasses}>
<span className="label">Check multiple messages to change their visibility to: </span> <span className="label">
{t('Check multiple messages to change their visibility to:')}{' '}
</span>
<Button <Button
type="primary" type="primary"
@ -234,7 +238,7 @@ export default function Chat() {
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'show')} disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'show')}
onClick={handleSubmitBulkShow} onClick={handleSubmitBulkShow}
> >
Show {t('Show')}
</Button> </Button>
<Button <Button
type="primary" type="primary"
@ -246,7 +250,7 @@ export default function Chat() {
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'hide')} disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'hide')}
onClick={handleSubmitBulkHide} onClick={handleSubmitBulkHide}
> >
Hide {t('Hide')}
</Button> </Button>
</div> </div>
<Table <Table

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext, ReactElement } from 'react'; import React, { useState, useEffect, useContext, ReactElement } from 'react';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../../utils/server-status-context'; import { ServerStatusContext } from '../../../utils/server-status-context';
import { import {
CONNECTED_CLIENTS, CONNECTED_CLIENTS,
@ -24,6 +25,7 @@ export default function ChatUsers() {
const [ipBans, setIPBans] = useState([]); const [ipBans, setIPBans] = useState([]);
const [clients, setClients] = useState([]); const [clients, setClients] = useState([]);
const [moderators, setModerators] = useState([]); const [moderators, setModerators] = useState([]);
const { t } = useTranslation();
const getInfo = async () => { const getInfo = async () => {
try { try {
@ -71,34 +73,50 @@ export default function ChatUsers() {
<> <>
<ClientTable data={clients} /> <ClientTable data={clients} />
<p className="description"> <p className="description">
Visit the{' '} {t('Visit the')}{' '}
<a <a
href="https://owncast.online/docs/viewers/?source=admin" href="https://owncast.online/docs/viewers/?source=admin"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
documentation {t('documentation')}
</a>{' '} </a>{' '}
to configure additional details about your viewers. {t('to configure additional details about your viewers.')}
</p> </p>
</> </>
) : ( ) : (
<p className="description"> <p className="description">
When a stream is active and chat is enabled, connected chat clients will be displayed here. {t(
'When a stream is active and chat is enabled, connected chat clients will be displayed here.',
)}
</p> </p>
); );
const connectedUserTabTitle = ( const connectedUserTabTitle = (
<span>Connected {online ? `(${clients.length})` : '(offline)'}</span> <span>
{t('Connected')} ({online ? clients.length : t('offline')})
</span>
); );
const bannedUsersTabTitle = <span>Banned Users ({disabledUsers.length})</span>; const bannedUsersTabTitle = (
<span>
{t('Banned Users')} ({disabledUsers.length})
</span>
);
const bannedUsersTable = <UserTable data={disabledUsers} />; const bannedUsersTable = <UserTable data={disabledUsers} />;
const bannedIPTabTitle = <span>IP Bans ({ipBans.length})</span>; const bannedIPTabTitle = (
<span>
{t('IP Bans')} ({ipBans.length})
</span>
);
const bannedIpTable = <BannedIPsTable data={ipBans} />; const bannedIpTable = <BannedIPsTable data={ipBans} />;
const moderatorUsersTabTitle = <span>Moderators ({moderators.length})</span>; const moderatorUsersTabTitle = (
<span>
{t('Moderators')} ({moderators.length})
</span>
);
const moderatorTable = <UserTable data={moderators} />; const moderatorTable = <UserTable data={moderators} />;
const items = [ const items = [

View File

@ -1,6 +1,7 @@
import { Row, Col, Typography, Alert, Spin } from 'antd'; import { Row, Col, Typography, Alert, Spin } from 'antd';
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../../utils/apis'; import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../../utils/apis';
import { Chart } from '../../components/admin/Chart'; import { Chart } from '../../components/admin/Chart';
import { StatisticItem } from '../../components/admin/StatisticItem'; import { StatisticItem } from '../../components/admin/StatisticItem';
@ -22,6 +23,7 @@ const SaveOutlined = dynamic(() => import('@ant-design/icons/SaveOutlined'), {
}); });
export default function HardwareInfo() { export default function HardwareInfo() {
const { t } = useTranslation();
const [hardwareStatus, setHardwareStatus] = useState({ const [hardwareStatus, setHardwareStatus] = useState({
cpu: [], // Array<TimedValue>(), cpu: [], // Array<TimedValue>(),
memory: [], // Array<TimedValue>(), memory: [], // Array<TimedValue>(),
@ -53,13 +55,13 @@ export default function HardwareInfo() {
if (!hardwareStatus.cpu) { if (!hardwareStatus.cpu) {
return ( return (
<div> <div>
<Typography.Title>Hardware Info</Typography.Title> <Typography.Title>{t('Hardware Info')}</Typography.Title>
<Alert <Alert
style={{ marginTop: '10px' }} style={{ marginTop: '10px' }}
banner banner
message="Please wait" message={t('Please wait')}
description="No hardware details have been collected yet." description={t('No hardware details have been collected yet.')}
type="info" type="info"
/> />
<Spin spinning style={{ width: '100%', margin: '10px' }} /> <Spin spinning style={{ width: '100%', margin: '10px' }} />
@ -73,19 +75,19 @@ export default function HardwareInfo() {
const series = [ const series = [
{ {
name: 'CPU', name: t('CPU'),
color: '#B63FFF', color: '#B63FFF',
data: hardwareStatus.cpu, data: hardwareStatus.cpu,
pointStyle: 'rect', pointStyle: 'rect',
}, },
{ {
name: 'Memory', name: t('Memory'),
color: '#2087E2', color: '#2087E2',
data: hardwareStatus.memory, data: hardwareStatus.memory,
pointStyle: 'circle', pointStyle: 'circle',
}, },
{ {
name: 'Disk', name: t('Disk'),
color: '#FF7700', color: '#FF7700',
data: hardwareStatus.disk, data: hardwareStatus.disk,
pointStyle: 'rectRounded', pointStyle: 'rectRounded',
@ -94,7 +96,7 @@ export default function HardwareInfo() {
return ( return (
<> <>
<Typography.Title>Hardware Info</Typography.Title> <Typography.Title>{t('Hardware Info')}</Typography.Title>
<br /> <br />
<div> <div>
<Row gutter={[16, 16]} justify="space-around"> <Row gutter={[16, 16]} justify="space-around">
@ -130,7 +132,7 @@ export default function HardwareInfo() {
</Col> </Col>
</Row> </Row>
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" /> <Chart title={`% ${t('used')}`} dataCollections={series} color="#FF7700" unit="%" />
</div> </div>
</> </>
); );

View File

@ -5,6 +5,7 @@ import Title from 'antd/lib/typography/Title';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { AdminLayout } from '../../components/layouts/AdminLayout'; import { AdminLayout } from '../../components/layouts/AdminLayout';
// Lazy loaded components // Lazy loaded components
@ -50,10 +51,12 @@ const SlidersTwoTone = dynamic(() => import('@ant-design/icons/SlidersTwoTone'),
}); });
export default function Help() { export default function Help() {
const { t } = useTranslation();
const questions = [ const questions = [
{ {
icon: <SettingTwoTone style={{ fontSize: '24px' }} />, icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to configure my owncast instance', title: t('I want to configure my owncast instance'),
content: ( content: (
<div> <div>
<a <a
@ -61,14 +64,14 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <CameraTwoTone style={{ fontSize: '24px' }} />, icon: <CameraTwoTone style={{ fontSize: '24px' }} />,
title: 'Help configuring my broadcasting software', title: t('Help configuring my broadcasting software'),
content: ( content: (
<div> <div>
<a <a
@ -76,14 +79,14 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <Html5TwoTone style={{ fontSize: '24px' }} />, icon: <Html5TwoTone style={{ fontSize: '24px' }} />,
title: 'I want to embed my stream into another site', title: t('I want to embed my stream into another site'),
content: ( content: (
<div> <div>
<a <a
@ -91,14 +94,14 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <EditTwoTone style={{ fontSize: '24px' }} />, icon: <EditTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to customize my website', title: t('I want to customize my website'),
content: ( content: (
<div> <div>
<a <a
@ -106,14 +109,14 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <SlidersTwoTone style={{ fontSize: '24px' }} />, icon: <SlidersTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to tweak my video output', title: t('I want to tweak my video output'),
content: ( content: (
<div> <div>
<a <a
@ -121,14 +124,14 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />, icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to use an external storage provider', title: t('I want to use an external storage provider'),
content: ( content: (
<div> <div>
<a <a
@ -136,7 +139,7 @@ export default function Help() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<LinkOutlined /> Learn more <LinkOutlined /> {t('Learn more')}
</a> </a>
</div> </div>
), ),
@ -146,58 +149,58 @@ export default function Help() {
const otherResources = [ const otherResources = [
{ {
icon: <BugTwoTone style={{ fontSize: '24px' }} />, icon: <BugTwoTone style={{ fontSize: '24px' }} />,
title: 'I found a bug', title: t('I found a bug'),
content: ( content: (
<div> <div>
If you found a bug, then please {t('If you found a bug, then please')}
<a <a
href="https://github.com/owncast/owncast/issues/new/choose" href="https://github.com/owncast/owncast/issues/new/choose"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{' '} {' '}
let us know {t('let us know')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />, icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />,
title: 'I have a general question', title: t('I have a general question'),
content: ( content: (
<div> <div>
Most general questions are answered in our {t('Most general questions are answered in our')}
<a <a
href="https://owncast.online/faq/?source=admin" href="https://owncast.online/faq/?source=admin"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{' '} {' '}
FAQ {t('FAQ')}
</a>{' '} </a>{' '}
or exist in our{' '} {t('or exist in our')}{' '}
<a <a
href="https://github.com/owncast/owncast/discussions" href="https://github.com/owncast/owncast/discussions"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
discussions {t('discussions')}
</a> </a>
</div> </div>
), ),
}, },
{ {
icon: <ApiTwoTone style={{ fontSize: '24px' }} />, icon: <ApiTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to build add-ons for Owncast', title: t('I want to build add-ons for Owncast'),
content: ( content: (
<div> <div>
You can build your own bots, overlays, tools and add-ons with our {t('You can build your own bots, overlays, tools and add-ons with our')}
<a <a
href="https://owncast.online/thirdparty?source=admin" href="https://owncast.online/thirdparty?source=admin"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
&nbsp;developer APIs.&nbsp; &nbsp;{t('developer APIs.')}&nbsp;
</a> </a>
</div> </div>
), ),
@ -206,11 +209,11 @@ export default function Help() {
return ( return (
<div className="help-page"> <div className="help-page">
<Title style={{ textAlign: 'center' }}>How can we help you?</Title> <Title style={{ textAlign: 'center' }}>{t('How can we help you?')}</Title>
<Row gutter={[16, 16]} justify="space-around" align="middle"> <Row gutter={[16, 16]} justify="space-around" align="middle">
<Col xs={24} lg={12} style={{ textAlign: 'center' }}> <Col xs={24} lg={12} style={{ textAlign: 'center' }}>
<Result status="500" /> <Result status="500" />
<Title level={2}>Troubleshooting</Title> <Title level={2}>{t('Troubleshooting')}</Title>
<Button <Button
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -218,12 +221,12 @@ export default function Help() {
icon={<LinkOutlined />} icon={<LinkOutlined />}
type="primary" type="primary"
> >
Fix your problems {t('Fix your problems')}
</Button> </Button>
</Col> </Col>
<Col xs={24} lg={12} style={{ textAlign: 'center' }}> <Col xs={24} lg={12} style={{ textAlign: 'center' }}>
<Result status="404" /> <Result status="404" />
<Title level={2}>Documentation</Title> <Title level={2}>{t('Documentation')}</Title>
<Button <Button
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -231,12 +234,12 @@ export default function Help() {
icon={<LinkOutlined />} icon={<LinkOutlined />}
type="primary" type="primary"
> >
Read the Docs {t('Read the Docs')}
</Button> </Button>
</Col> </Col>
</Row> </Row>
<Divider /> <Divider />
<Title level={2}>Common tasks</Title> <Title level={2}>{t('Common tasks')}</Title>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{questions.map(question => ( {questions.map(question => (
<Col xs={24} lg={12} key={question.title}> <Col xs={24} lg={12} key={question.title}>
@ -247,7 +250,7 @@ export default function Help() {
))} ))}
</Row> </Row>
<Divider /> <Divider />
<Title level={2}>Other</Title> <Title level={2}>{t('Other')}</Title>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{otherResources.map(question => ( {otherResources.map(question => (
<Col xs={24} lg={12} key={question.title}> <Col xs={24} lg={12} key={question.title}>

View File

@ -3,6 +3,7 @@ import React, { useState, useEffect, useContext, ReactElement } from 'react';
import { Skeleton, Card, Statistic, Row, Col } from 'antd'; import { Skeleton, Card, Statistic, Row, Col } from 'antd';
import { formatDistanceToNow, formatRelative } from 'date-fns'; import { formatDistanceToNow, formatRelative } from 'date-fns';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { ServerStatusContext } from '../../utils/server-status-context'; import { ServerStatusContext } from '../../utils/server-status-context';
import { LogTable } from '../../components/admin/LogTable'; import { LogTable } from '../../components/admin/LogTable';
import { Offline } from '../../components/admin/Offline'; import { Offline } from '../../components/admin/Offline';
@ -39,6 +40,8 @@ function streamDetailsFormatter(streamDetails) {
} }
export default function Home() { export default function Home() {
const { t } = useTranslation();
const serverStatusData = useContext(ServerStatusContext); const serverStatusData = useContext(ServerStatusContext);
const { broadcaster, serverConfig: configData } = serverStatusData || {}; const { broadcaster, serverConfig: configData } = serverStatusData || {};
const { remoteAddr, streamDetails } = broadcaster || {}; const { remoteAddr, streamDetails } = broadcaster || {};
@ -101,12 +104,12 @@ export default function Home() {
<div className="stream-details-item-container"> <div className="stream-details-item-container">
<Statistic <Statistic
className="stream-details-item" className="stream-details-item"
title="Outbound Video Stream" title={t('Outbound Video Stream')}
value={videoSetting} value={videoSetting}
/> />
<Statistic <Statistic
className="stream-details-item" className="stream-details-item"
title="Outbound Audio Stream" title={t('Outbound Audio Stream')}
value={audioSetting} value={audioSetting}
/> />
</div> </div>
@ -130,17 +133,17 @@ export default function Home() {
<Row gutter={[16, 16]} align="middle"> <Row gutter={[16, 16]} align="middle">
<Col span={8} sm={24} md={8}> <Col span={8} sm={24} md={8}>
<Statistic <Statistic
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`} title={`${t('Stream started')} ${formatRelative(broadcastDate, Date.now())}`}
value={formatDistanceToNow(broadcastDate)} value={formatDistanceToNow(broadcastDate)}
prefix={<ClockCircleOutlined />} prefix={<ClockCircleOutlined />}
/> />
</Col> </Col>
<Col span={8} sm={24} md={8}> <Col span={8} sm={24} md={8}>
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} /> <Statistic title={t('Viewers')} value={viewerCount} prefix={<UserOutlined />} />
</Col> </Col>
<Col span={8} sm={24} md={8}> <Col span={8} sm={24} md={8}>
<Statistic <Statistic
title="Peak viewer count" title={t('Peak viewer count')}
value={sessionPeakViewerCount} value={sessionPeakViewerCount}
prefix={<UserOutlined />} prefix={<UserOutlined />}
/> />
@ -154,28 +157,28 @@ export default function Home() {
<Col className="stream-details" span={12} sm={24} md={24} lg={12}> <Col className="stream-details" span={12} sm={24} md={24} lg={12}>
<Card <Card
size="small" size="small"
title="Outbound Stream Details" title={t('Outbound Stream Details')}
type="inner" type="inner"
className="outbound-details" className="outbound-details"
> >
{videoQualitySettings} {videoQualitySettings}
</Card> </Card>
<Card size="small" title="Inbound Stream Details" type="inner"> <Card size="small" title={t('Inbound Stream Details')} type="inner">
<Statistic <Statistic
className="stream-details-item" className="stream-details-item"
title="Input" title={t('Input')}
value={`${encoder} ${formatIPAddress(remoteAddr)}`} value={`${encoder} ${formatIPAddress(remoteAddr)}`}
/> />
<Statistic <Statistic
className="stream-details-item" className="stream-details-item"
title="Inbound Video Stream" title={t('Inbound Video Stream')}
value={streamDetails} value={streamDetails}
formatter={streamDetailsFormatter} formatter={streamDetailsFormatter}
/> />
<Statistic <Statistic
className="stream-details-item" className="stream-details-item"
title="Inbound Audio Stream" title={t('Inbound Audio Stream')}
value={streamAudioDetailString} value={streamAudioDetailString}
/> />
</Card> </Card>

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useContext, ReactElement } from 'react';
import { Row, Col, Typography, MenuProps, Dropdown, Spin, Alert } from 'antd'; import { Row, Col, Typography, MenuProps, Dropdown, Spin, Alert } from 'antd';
import { getUnixTime, sub } from 'date-fns'; import { getUnixTime, sub } from 'date-fns';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTranslation } from 'next-export-i18n';
import { Chart } from '../../components/admin/Chart'; import { Chart } from '../../components/admin/Chart';
import { StatisticItem } from '../../components/admin/StatisticItem'; import { StatisticItem } from '../../components/admin/StatisticItem';
import { ViewerTable } from '../../components/admin/ViewerTable'; import { ViewerTable } from '../../components/admin/ViewerTable';
@ -26,6 +27,7 @@ const FETCH_INTERVAL = 60 * 1000; // 1 min
export default function ViewersOverTime() { export default function ViewersOverTime() {
const context = useContext(ServerStatusContext); const context = useContext(ServerStatusContext);
const { t } = useTranslation();
const { online, broadcaster, viewerCount, overallPeakViewerCount, sessionPeakViewerCount } = const { online, broadcaster, viewerCount, overallPeakViewerCount, sessionPeakViewerCount } =
context || {}; context || {};
let streamStart; let streamStart;
@ -34,13 +36,13 @@ export default function ViewersOverTime() {
} }
const times = [ const times = [
{ title: 'Current stream', start: streamStart }, { title: t('Current stream'), start: streamStart },
{ title: 'Last 12 hours', start: sub(new Date(), { hours: 12 }) }, { title: t('Last 12 hours'), start: sub(new Date(), { hours: 12 }) },
{ title: 'Last 24 hours', start: sub(new Date(), { hours: 24 }) }, { title: t('Last 24 hours'), start: sub(new Date(), { hours: 24 }) },
{ title: 'Last 7 days', start: sub(new Date(), { days: 7 }) }, { title: t('Last 7 days'), start: sub(new Date(), { days: 7 }) },
{ title: 'Last 30 days', start: sub(new Date(), { days: 30 }) }, { title: t('Last 30 days'), start: sub(new Date(), { days: 30 }) },
{ title: 'Last 3 months', start: sub(new Date(), { months: 3 }) }, { title: t('Last 3 months'), start: sub(new Date(), { months: 3 }) },
{ title: 'Last 6 months', start: sub(new Date(), { months: 6 }) }, { title: t('Last 6 months'), start: sub(new Date(), { months: 6 }) },
]; ];
const [loadingChart, setLoadingChart] = useState(true); const [loadingChart, setLoadingChart] = useState(true);
@ -94,13 +96,13 @@ export default function ViewersOverTime() {
return ( return (
<> <>
<Typography.Title>Viewer Info</Typography.Title> <Typography.Title>{t('Viewer Info')}</Typography.Title>
<br /> <br />
<Row gutter={[16, 16]} justify="space-around"> <Row gutter={[16, 16]} justify="space-around">
{online && ( {online && (
<Col span={8} md={8}> <Col span={8} md={8}>
<StatisticItem <StatisticItem
title="Current viewers" title={t('Current viewers')}
value={viewerCount.toString()} value={viewerCount.toString()}
prefix={<UserOutlined />} prefix={<UserOutlined />}
/> />
@ -108,14 +110,14 @@ export default function ViewersOverTime() {
)} )}
<Col md={online ? 8 : 12}> <Col md={online ? 8 : 12}>
<StatisticItem <StatisticItem
title={online ? 'Max viewers this stream' : 'Max viewers last stream'} title={online ? t('Max viewers this stream') : t('Max viewers last stream')}
value={sessionPeakViewerCount.toString()} value={sessionPeakViewerCount.toString()}
prefix={<UserOutlined />} prefix={<UserOutlined />}
/> />
</Col> </Col>
<Col md={online ? 8 : 12}> <Col md={online ? 8 : 12}>
<StatisticItem <StatisticItem
title="All-time max viewers" title={t('max viewers')}
value={overallPeakViewerCount.toString()} value={overallPeakViewerCount.toString()}
prefix={<UserOutlined />} prefix={<UserOutlined />}
/> />
@ -125,8 +127,8 @@ export default function ViewersOverTime() {
<Alert <Alert
style={{ marginTop: '10px' }} style={{ marginTop: '10px' }}
banner banner
message="Please wait" message={t('Please wait')}
description="No viewer data has been collected yet." description={t('No viewer data has been collected yet.')}
type="info" type="info"
/> />
)} )}
@ -134,7 +136,7 @@ export default function ViewersOverTime() {
<Spin spinning={!viewerInfo.length || loadingChart}> <Spin spinning={!viewerInfo.length || loadingChart}>
{viewerInfo.length > 0 && ( {viewerInfo.length > 0 && (
<Chart <Chart
title="Viewers" title={t('Viewers')}
data={viewerInfo} data={viewerInfo}
color="#2087E2" color="#2087E2"
unit="viewers" unit="viewers"