diff --git a/.github/workflows/auto_assign.yml b/.github/workflows/auto_assign.yml index 87e9c8f37..dfbddb6a4 100644 --- a/.github/workflows/auto_assign.yml +++ b/.github/workflows/auto_assign.yml @@ -14,7 +14,7 @@ jobs: # Auto assign PR to author - name: Auto Assign PR to Author if: github.event_name == 'pull_request' - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -33,7 +33,7 @@ jobs: - name: Label PRs and Issues env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/package-lock.json b/package-lock.json index 0584eb3d2..a7a258109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "mini-css-extract-plugin": "2.9.2", "postcss-loader": "8.1.1", "readdirp": "4.0.2", + "recast": "0.23.11", "terser-webpack-plugin": "5.3.10", "thread-loader": "^4.0.4", "ts-loader": "^9.5.1", @@ -4786,6 +4787,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -12497,6 +12511,23 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -13976,6 +14007,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "dev": true, diff --git a/package.json b/package.json index 36e556149..51bd9036c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "start-prod": "webpack serve --mode production", "build": "webpack --mode production", "test": "jest", - "lint": "eslint src" + "lint": "eslint src", + "scan-translations": "npx jest ./tests/i18nScan.test.js" }, "dependencies": { "@babel/runtime": "7.26.0", @@ -70,6 +71,7 @@ "mini-css-extract-plugin": "2.9.2", "postcss-loader": "8.1.1", "readdirp": "4.0.2", + "recast": "0.23.11", "terser-webpack-plugin": "5.3.10", "thread-loader": "^4.0.4", "ts-loader": "^9.5.1", diff --git a/src/common/Toast/ToastItem/ToastItem.js b/src/common/Toast/ToastItem/ToastItem.js index 94b5a98b4..3a317ab1b 100644 --- a/src/common/Toast/ToastItem/ToastItem.js +++ b/src/common/Toast/ToastItem/ToastItem.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); @@ -8,6 +9,7 @@ const { Button } = require('stremio/components'); const styles = require('./styles'); const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) => { + const { t } = useTranslation(); const type = React.useMemo(() => { return ['success', 'alert', 'info', 'error'].includes(props.type) ? props.type @@ -74,7 +76,7 @@ const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) => null } - diff --git a/src/components/AddonDetailsModal/AddonDetails/AddonDetails.js b/src/components/AddonDetailsModal/AddonDetails/AddonDetails.js index da5fde198..78c744b96 100644 --- a/src/components/AddonDetailsModal/AddonDetails/AddonDetails.js +++ b/src/components/AddonDetailsModal/AddonDetails/AddonDetails.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); @@ -8,6 +9,7 @@ const { default: Image } = require('stremio/components/Image'); const styles = require('./styles'); const AddonDetails = ({ className, id, name, version, logo, description, types, transportUrl, official }) => { + const { t } = useTranslation(); const renderLogoFallback = React.useCallback(() => ( ), []); @@ -24,7 +26,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types, {typeof name === 'string' && name.length > 0 ? name : id} { typeof version === 'string' && version.length > 0 ? - v. {version} + {t('ADDON_VERSION_SHORT', {version})} : null } @@ -41,7 +43,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types, { typeof transportUrl === 'string' && transportUrl.length > 0 ?
- URL: + {`${t('URL')}:`} {transportUrl}
: @@ -50,7 +52,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types, { Array.isArray(types) && types.length > 0 ?
- Supported types: + {`${t('ADDON_SUPPORTED_TYPES')}:`} { types.length === 1 ? @@ -66,7 +68,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types, { !official ?
-
Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.
+
{t('ADDON_DISCLAIMER')}
: null diff --git a/src/components/AddonDetailsModal/AddonDetailsModal.js b/src/components/AddonDetailsModal/AddonDetailsModal.js index 332eab364..a89a68b77 100644 --- a/src/components/AddonDetailsModal/AddonDetailsModal.js +++ b/src/components/AddonDetailsModal/AddonDetailsModal.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const ModalDialog = require('stremio/components/ModalDialog'); const { withCoreSuspender } = require('stremio/common/CoreSuspender'); @@ -43,13 +44,14 @@ function withRemoteAndLocalAddon(AddonDetails) { } const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => { + const { t } = useTranslation(); const { core } = useServices(); const platform = usePlatform(); const addonDetails = useAddonDetails(transportUrl); const modalButtons = React.useMemo(() => { const cancelButton = { className: styles['cancel-button'], - label: 'Cancel', + label: t('BUTTON_CANCEL'), props: { onClick: (event) => { if (typeof onCloseRequest === 'function') { @@ -67,7 +69,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => { addonDetails.remoteAddon.content.content.manifest.behaviorHints.configurable ? { className: styles['configure-button'], - label: 'Configure', + label: t('ADDON_CONFIGURE'), props: { onClick: (event) => { platform.openExternal(transportUrl.replace('manifest.json', 'configure')); @@ -86,7 +88,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => { const toggleButton = addonDetails.localAddon !== null ? { className: styles['uninstall-button'], - label: 'Uninstall', + label: t('ADDON_UNINSTALL'), props: { onClick: (event) => { core.transport.dispatch({ @@ -113,7 +115,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => { { className: styles['install-button'], - label: 'Install', + label: t('ADDON_INSTALL'), props: { onClick: (event) => { core.transport.dispatch({ @@ -141,21 +143,21 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => { return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null; }, [addonDetails.remoteAddon]); return ( - + { addonDetails.selected === null ?
- Loading addon manifest + {t('ADDON_LOADING_MANIFEST')}
: addonDetails.remoteAddon === null || addonDetails.remoteAddon.content.type === 'Loading' ?
- Loading addon manifest from {addonDetails.selected.transportUrl} + {t('ADDON_LOADING_MANIFEST_FROM', { origin: addonDetails.selected.transportUrl})}
: addonDetails.remoteAddon.content.type === 'Err' && addonDetails.localAddon === null ?
- Failed to get addon manifest from {addonDetails.selected.transportUrl} + {t('ADDON_LOADING_MANIFEST_FAILED', {origin: addonDetails.selected.transportUrl})}
{addonDetails.remoteAddon.content.content.message}
: @@ -174,17 +176,18 @@ AddonDetailsModal.propTypes = { onCloseRequest: PropTypes.func }; -const AddonDetailsModalFallback = ({ onCloseRequest }) => ( - { + const { t } = useTranslation(); + return
- Loading addon manifest + {t('ADDON_LOADING_MANIFEST')}
-
-); +
; +}; AddonDetailsModalFallback.propTypes = AddonDetailsModal.propTypes; diff --git a/src/components/Checkbox/Checkbox.less b/src/components/Checkbox/Checkbox.less index a84244ce9..9276990b3 100644 --- a/src/components/Checkbox/Checkbox.less +++ b/src/components/Checkbox/Checkbox.less @@ -23,6 +23,7 @@ .link { font-size: 0.9rem; color: var(--primary-accent-color); + margin-left: 0.5rem; &:hover { text-decoration: underline; diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index da4ae33eb..b252006eb 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -80,7 +80,6 @@ const Checkbox = React.forwardRef(({ name, disabled, cl
{label} - {' '} { href && link ?
diff --git a/src/components/Multiselect/Multiselect.js b/src/components/Multiselect/Multiselect.js index 0e353eef4..c2bf855d1 100644 --- a/src/components/Multiselect/Multiselect.js +++ b/src/components/Multiselect/Multiselect.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); @@ -11,6 +12,7 @@ const useBinaryState = require('stremio/common/useBinaryState'); const styles = require('./styles'); const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => { + const { t } = useTranslation(); const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); const filteredOptions = React.useMemo(() => { return Array.isArray(options) ? @@ -122,7 +124,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt )) :
-
No options available
+
{t('NO_OPTIONS')}
}
diff --git a/src/components/MultiselectMenu/Dropdown/Dropdown.less b/src/components/MultiselectMenu/Dropdown/Dropdown.less index 3bea17d22..2373241f2 100644 --- a/src/components/MultiselectMenu/Dropdown/Dropdown.less +++ b/src/components/MultiselectMenu/Dropdown/Dropdown.less @@ -2,7 +2,7 @@ @import (reference) '~stremio/common/screen-sizes.less'; -@parent-height: 10rem; +@parent-height: 12rem; .dropdown { background: var(--modal-background-color); diff --git a/src/components/SharePrompt/SharePrompt.js b/src/components/SharePrompt/SharePrompt.js index 0a9843a02..af4393b8a 100644 --- a/src/components/SharePrompt/SharePrompt.js +++ b/src/components/SharePrompt/SharePrompt.js @@ -70,7 +70,7 @@ const SharePrompt = ({ className, url }) => { onClick={selectInputContent} tabIndex={-1} /> - diff --git a/src/components/Video/Video.js b/src/components/Video/Video.js index 94fbefcc7..78a1e64f6 100644 --- a/src/components/Video/Video.js +++ b/src/components/Video/Video.js @@ -1,9 +1,9 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); -const { t } = require('i18next'); const { useRouteFocused } = require('stremio-router'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button, Image, Popup } = require('stremio/components'); @@ -13,6 +13,7 @@ const VideoPlaceholder = require('./VideoPlaceholder'); const styles = require('./styles'); const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => { + const { t } = useTranslation(); const routeFocused = useRouteFocused(); const profile = useProfile(); const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); @@ -107,12 +108,12 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc { released instanceof Date && !isNaN(released.getTime()) ?
- {released.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })} + {released.toLocaleString(profile.settings.interfaceLanguage, { year: 'numeric', month: 'short', day: 'numeric' })}
: scheduled ? -
- TBA +
+ {t('TBA')}
: null @@ -121,7 +122,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc { upcoming && !watched ?
-
Upcoming
+
{t('UPCOMING')}
: null @@ -130,7 +131,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc watched ?
-
Watched
+
{t('CTX_WATCHED')}
: null @@ -145,10 +146,10 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc const renderMenu = React.useMemo(() => function renderMenu() { return (
- -
{ typeof version === 'string' && version.length > 0 ? -
v.{version}
+
{t('ADDON_VERSION_SHORT', {version})}
: null } diff --git a/src/routes/Addons/Addons.js b/src/routes/Addons/Addons.js index 451101cbe..da9544d51 100644 --- a/src/routes/Addons/Addons.js +++ b/src/routes/Addons/Addons.js @@ -124,7 +124,7 @@ const Addons = ({ urlParams, queryParams }) => { value={search} onChange={searchInputOnChange} /> -
@@ -132,12 +132,12 @@ const Addons = ({ urlParams, queryParams }) => { installedAddons.selected !== null ? installedAddons.selectable.types.length === 0 ?
- No addons ware installed! + {t('NO_ADDONS')}
: installedAddons.catalog.length === 0 ?
- No addons ware installed for that type! + {t('NO_ADDONS_FOR_TYPE')}
:
@@ -216,7 +216,7 @@ const Addons = ({ urlParams, queryParams }) => {
{ filtersModalOpen ? - + {selectInputs.map((selectInput, index) => ( { {typeof sharedAddon.manifest.name === 'string' && sharedAddon.manifest.name.length > 0 ? sharedAddon.manifest.name : sharedAddon.manifest.id} { typeof sharedAddon.manifest.version === 'string' && sharedAddon.manifest.version.length > 0 ? - v. {sharedAddon.manifest.version} + {t('ADDON_VERSION_SHORT', { version: sharedAddon.manifest.version })} : null } diff --git a/src/routes/Addons/useSelectableInputs.js b/src/routes/Addons/useSelectableInputs.js index a8af37fbe..003da745d 100644 --- a/src/routes/Addons/useSelectableInputs.js +++ b/src/routes/Addons/useSelectableInputs.js @@ -10,8 +10,8 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => { .concat(installedAddons.selectable.catalogs) .map(({ name, deepLinks }) => ({ value: deepLinks.addons, - label: t.stringWithPrefix(name, 'ADDON_'), - title: t.stringWithPrefix(name, 'ADDON_'), + label: t.stringWithPrefix(name.toUpperCase(), 'ADDON_'), + title: t.stringWithPrefix(name.toUpperCase(), 'ADDON_'), })), value: selectedCatalog ? selectedCatalog.deepLinks.addons : undefined, title: remoteAddons.selected !== null ? diff --git a/src/routes/Calendar/Details/Details.tsx b/src/routes/Calendar/Details/Details.tsx index cce550ee9..a03708572 100644 --- a/src/routes/Calendar/Details/Details.tsx +++ b/src/routes/Calendar/Details/Details.tsx @@ -1,6 +1,7 @@ // Copyright (C) 2017-2024 Smart code 203358507 import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import Icon from '@stremio/stremio-icons/react'; import { Button } from 'stremio/components'; import styles from './Details.less'; @@ -11,6 +12,7 @@ type Props = { }; const Details = ({ selected, items }: Props) => { + const { t } = useTranslation(); const videos = useMemo(() => { return items.find(({ date }) => date.day === selected?.day)?.items ?? []; }, [selected, items]); @@ -33,7 +35,7 @@ const Details = ({ selected, items }: Props) => { { !videos.length ?
- No new episodes for this day + {t('CALENDAR_NO_NEW_EPISODES')}
: null diff --git a/src/routes/Discover/Discover.js b/src/routes/Discover/Discover.js index 1c2b6f122..1fc16c706 100644 --- a/src/routes/Discover/Discover.js +++ b/src/routes/Discover/Discover.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); @@ -14,6 +15,7 @@ const styles = require('./styles'); const SCROLL_TO_BOTTOM_THRESHOLD = 400; const Discover = ({ urlParams, queryParams }) => { + const { t } = useTranslation(); const { core } = useServices(); const [discover, loadNextPage] = useDiscover(urlParams, queryParams); const [selectInputs, hasNextPage] = useSelectableInputs(discover); @@ -111,7 +113,7 @@ const Discover = ({ urlParams, queryParams }) => { /> ))}
-
@@ -119,9 +121,9 @@ const Discover = ({ urlParams, queryParams }) => { { discover.catalog !== null && !discover.catalog.installed ?
-
Addon is not installed. Install now?
-
: @@ -132,7 +134,7 @@ const Discover = ({ urlParams, queryParams }) => {
{' -
No catalog selected!
+
{t('NO_CATALOG_SELECTED')}
: @@ -201,7 +203,7 @@ const Discover = ({ urlParams, queryParams }) => {
{ inputsModalOpen ? - + {selectInputs.map(({ title, options, value, onSelect }, index) => ( { return { isRequired: isRequired, options: options.map(({ value, deepLinks }) => ({ - label: typeof value === 'string' ? t.stringWithPrefix(value) : t.string('NONE'), + label: typeof value === 'string' ? t.string(value) : t.string('NONE'), value: JSON.stringify({ href: deepLinks.discover, value @@ -60,8 +60,8 @@ const mapSelectableInputs = (discover, t) => { value: selectedExtra.value, }), title: options.some(({ selected, value }) => selected && value === null) ? - () => t.stringWithPrefix(name, 'SELECT_') - : t.stringWithPrefix(selectedExtra.value), + () => t.string(name.toUpperCase()) + : t.string(selectedExtra.value), onSelect: (value) => { const { href } = JSON.parse(value); window.location = href; diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index 6af8f0167..48acadcd4 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -299,10 +299,10 @@ const Intro = ({ queryParams }) => { {'
- Freedom to Stream + {t('WEBSITE_SLOGAN_NEW_NEW')}
- All the Video Content You Enjoy in One Place + {t('WEBSITE_SLOGAN_ALL')}
@@ -311,7 +311,7 @@ const Intro = ({ queryParams }) => { ref={emailRef} className={styles['credentials-text-input']} type={'email'} - placeholder={'Email'} + placeholder={t('EMAIL')} value={state.email} onChange={emailOnChange} onSubmit={emailOnSubmit} @@ -320,7 +320,7 @@ const Intro = ({ queryParams }) => { ref={passwordRef} className={styles['credentials-text-input']} type={'password'} - placeholder={'Password'} + placeholder={t('PASSWORD')} value={state.password} onChange={passwordOnChange} onSubmit={passwordOnSubmit} @@ -332,37 +332,37 @@ const Intro = ({ queryParams }) => { ref={confirmPasswordRef} className={styles['credentials-text-input']} type={'password'} - placeholder={'Confirm Password'} + placeholder={t('PASSWORD_CONFIRM')} value={state.confirmPassword} onChange={confirmPasswordOnChange} onSubmit={confirmPasswordOnSubmit} /> :
- +
} { @@ -372,22 +372,22 @@ const Intro = ({ queryParams }) => { null }
{ state.form === SIGNUP_FORM ? : null @@ -395,7 +395,7 @@ const Intro = ({ queryParams }) => { { state.form === LOGIN_FORM ? : null @@ -403,7 +403,7 @@ const Intro = ({ queryParams }) => { { state.form === SIGNUP_FORM ? : null @@ -421,7 +421,7 @@ const Intro = ({ queryParams }) => {
-
Authenticating...
+
{t('AUTHENTICATING')}
diff --git a/src/routes/Intro/PasswordResetModal/PasswordResetModal.js b/src/routes/Intro/PasswordResetModal/PasswordResetModal.js index 6f295fdd6..8c69c1489 100644 --- a/src/routes/Intro/PasswordResetModal/PasswordResetModal.js +++ b/src/routes/Intro/PasswordResetModal/PasswordResetModal.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const { useRouteFocused } = require('stremio-router'); const { usePlatform } = require('stremio/common'); @@ -9,6 +10,7 @@ const CredentialsTextInput = require('../CredentialsTextInput'); const styles = require('./styles'); const PasswordResetModal = ({ email, onCloseRequest }) => { + const { t } = useTranslation(); const routeFocused = useRouteFocused(); const platform = usePlatform(); const [error, setError] = React.useState(''); @@ -23,13 +25,13 @@ const PasswordResetModal = ({ email, onCloseRequest }) => { return [ { className: styles['cancel-button'], - label: 'Cancel', + label: t('BUTTON_CANCEL'), props: { onClick: onCloseRequest } }, { - label: 'Send', + label: t('SEND'), props: { onClick: goToPasswordReset } @@ -45,7 +47,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => { } }, [routeFocused]); return ( - + { + const { t } = useTranslation(); const profile = useProfile(); const notifications = useNotifications(); const [library, loadNextPage] = useLibrary(model, urlParams, queryParams); @@ -86,7 +88,7 @@ const Library = ({ model, urlParams, queryParams }) => { src={require('/images/empty.png')} alt={' '} /> -
{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!
+
{model === 'library' ? t('LIBRARY_NOT_LOADED') : t('BOARD_CONTINUE_WATCHING_NOT_LOADED')}
: @@ -97,7 +99,7 @@ const Library = ({ model, urlParams, queryParams }) => { src={require('/images/empty.png')} alt={' '} /> -
Empty {model === 'library' ? 'Library' : 'Continue Watching'}
+
{model === 'library' ? t('LIBRARY_EMPTY') : t('BOARD_CONTINUE_WATCHING_EMPTY')}
:
diff --git a/src/routes/MetaDetails/MetaDetails.js b/src/routes/MetaDetails/MetaDetails.js index 8a50b59c1..da79df285 100644 --- a/src/routes/MetaDetails/MetaDetails.js +++ b/src/routes/MetaDetails/MetaDetails.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { useServices } = require('stremio/services'); @@ -14,6 +15,7 @@ const useMetaExtensionTabs = require('./useMetaExtensionTabs'); const styles = require('./styles'); const MetaDetails = ({ urlParams, queryParams }) => { + const { t } = useTranslation(); const { core } = useServices(); const metaDetails = useMetaDetails(urlParams); const [season, setSeason] = useSeason(urlParams, queryParams); @@ -129,20 +131,20 @@ const MetaDetails = ({ urlParams, queryParams }) => {
{' -
No meta was selected!
+
{t('ERR_NO_META_SELECTED')}
: metaDetails.metaItem === null ?
{' -
No addons were requested for this meta!
+
{t('ERR_NO_ADDONS_FOR_META')}
: metaDetails.metaItem.content.type === 'Err' ?
{' -
No metadata was found!
+
{t('ERR_NO_META_FOUND')}
: metaDetails.metaItem.content.type === 'Loading' ? diff --git a/src/routes/MetaDetails/StreamsList/StreamsList.js b/src/routes/MetaDetails/StreamsList/StreamsList.js index 627b41857..eebc0c3cf 100644 --- a/src/routes/MetaDetails/StreamsList/StreamsList.js +++ b/src/routes/MetaDetails/StreamsList/StreamsList.js @@ -133,7 +133,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => { : null } {' -
No addons were requested for streams!
+
{t('ERR_NO_ADDONS_FOR_STREAMS')}
: props.streams.every((streams) => streams.content.type === 'Err') ? diff --git a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBar.js b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBar.js index 51b51f0e7..ea870a931 100644 --- a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBar.js +++ b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBar.js @@ -13,7 +13,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => { const options = React.useMemo(() => { return seasons.map((season) => ({ value: String(season), - label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL') + label: season > 0 ? t('SEASON_NUMBER', { season }) : t('SPECIAL') })); }, [seasons]); const selectedSeason = React.useMemo(() => { @@ -56,19 +56,19 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => { return (
- 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')} + title={season > 0 ? t('SEASON_NUMBER', { season }) : t('SPECIAL')} value={selectedSeason} onSelect={seasonOnSelect} /> -
diff --git a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js index d767769ec..4013e0b64 100644 --- a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js +++ b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js @@ -1,24 +1,26 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); const styles = require('./styles'); const SeasonsBarPlaceholder = ({ className }) => { + const { t } = useTranslation(); return (
-
Prev
+
{t('SEASON_PREV')}
-
Season 1
+
{t('SEASON_NUMBER', { season: 1 })}
-
Next
+
{t('SEASON_NEXT')}
diff --git a/src/routes/MetaDetails/VideosList/VideosList.js b/src/routes/MetaDetails/VideosList/VideosList.js index a47b2f517..88d53d427 100644 --- a/src/routes/MetaDetails/VideosList/VideosList.js +++ b/src/routes/MetaDetails/VideosList/VideosList.js @@ -5,6 +5,7 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const { t } = require('i18next'); const { useServices } = require('stremio/services'); +const { useProfile } = require('stremio/common'); const { Image, SearchBar, Toggle, Video } = require('stremio/components'); const SeasonsBar = require('./SeasonsBar'); const { default: EpisodePicker } = require('../EpisodePicker'); @@ -12,6 +13,7 @@ const styles = require('./styles'); const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => { const { core } = useServices(); + const profile = useProfile(); const showNotificationsToggle = React.useMemo(() => { return metaItem?.content?.content?.inLibrary && metaItem?.content?.content?.videos?.length; }, [metaItem]); @@ -122,7 +124,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
{' -
No videos found for this meta!
+
{t('ERR_NO_VIDEOS_FOR_META')}
: @@ -158,7 +160,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, return search.length === 0 || ( (typeof video.title === 'string' && video.title.toLowerCase().includes(search.toLowerCase())) || - (!isNaN(video.released.getTime()) && video.released.toLocaleString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }).toLowerCase().includes(search.toLowerCase())) + (!isNaN(video.released.getTime()) && video.released.toLocaleString(profile.settings.interfaceLanguage, { year: '2-digit', month: 'short', day: 'numeric' }).toLowerCase().includes(search.toLowerCase())) ); }) .map((video, index) => ( diff --git a/src/routes/NotFound/NotFound.js b/src/routes/NotFound/NotFound.js index 323dfd866..d984496bc 100644 --- a/src/routes/NotFound/NotFound.js +++ b/src/routes/NotFound/NotFound.js @@ -1,15 +1,17 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const { HorizontalNavBar, Image } = require('stremio/components'); const styles = require('./styles'); const NotFound = () => { + const { t } = useTranslation(); return (
{ src={require('/images/empty.png')} alt={' '} /> -
Page not found!
+
{t('PAGE_NOT_FOUND')}
); diff --git a/src/routes/Player/NextVideoPopup/NextVideoPopup.js b/src/routes/Player/NextVideoPopup/NextVideoPopup.js index a02d2377e..1f6bcbbe6 100644 --- a/src/routes/Player/NextVideoPopup/NextVideoPopup.js +++ b/src/routes/Player/NextVideoPopup/NextVideoPopup.js @@ -7,8 +7,10 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { CONSTANTS, useProfile } = require('stremio/common'); const { Button, Image } = require('stremio/components'); const styles = require('./styles'); +const { useTranslation } = require('react-i18next'); const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideoRequested }) => { + const { t } = useTranslation(); const profile = useProfile(); const blurPosterImage = profile.settings.hideSpoilers && metaItem.type === 'series'; const watchNowButtonRef = React.useRef(null); @@ -65,7 +67,7 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo { typeof metaItem?.name === 'string' ?
- Next on { metaItem.name } + {t('PLAYER_NEXT_VIDEO_TITLE_SHORT')} { metaItem.name }
: null @@ -82,11 +84,11 @@ const NextVideoPopup = ({ className, metaItem, nextVideo, onDismiss, onNextVideo
diff --git a/src/routes/Player/SideDrawer/SideDrawer.less b/src/routes/Player/SideDrawer/SideDrawer.less index 2d2178c3e..0fe22f58a 100644 --- a/src/routes/Player/SideDrawer/SideDrawer.less +++ b/src/routes/Player/SideDrawer/SideDrawer.less @@ -57,7 +57,7 @@ .info { padding: @padding; overflow-y: auto; - flex: none; + flex: 1; .side-drawer-meta-preview { .action-buttons-container { @@ -78,12 +78,6 @@ } } -@media screen and (max-width: @small) { - .side-drawer { - max-width: 40dvw; - } -} - @media @phone-portrait { .side-drawer { max-width: 100dvw; diff --git a/src/routes/Player/StatisticsMenu/StatisticsMenu.js b/src/routes/Player/StatisticsMenu/StatisticsMenu.js index 69ee2bf8d..b5f5232ea 100644 --- a/src/routes/Player/StatisticsMenu/StatisticsMenu.js +++ b/src/routes/Player/StatisticsMenu/StatisticsMenu.js @@ -1,20 +1,22 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const classNames = require('classnames'); const PropTypes = require('prop-types'); const styles = require('./styles.less'); const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => { + const { t } = useTranslation(); return (
- Statistics + {t('PLAYER_STATISTICS')}
- Peers + {t('PLAYER_PEERS')}
{ peers } @@ -22,15 +24,15 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
- Speed + {t('PLAYER_SPEED')}
- { speed } MB/s + {`${speed} ${t('MB_S')}`}
- Completed + {t('PLAYER_COMPLETED')}
{ Math.min(completed, 100) } % @@ -39,7 +41,7 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
- Info Hash + {t('PLAYER_INFO_HASH')}
{ infoHash } diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js b/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js index a57754793..ea7948b83 100644 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js +++ b/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); +const { useTranslation } = require('react-i18next'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); @@ -8,6 +9,7 @@ const { Button } = require('stremio/components'); const styles = require('./styles'); const DiscreteSelectInput = ({ className, value, label, disabled, dataset, onChange }) => { + const { t } = useTranslation(); const buttonOnClick = React.useCallback((event) => { if (typeof onChange === 'function') { onChange({ @@ -22,7 +24,7 @@ const DiscreteSelectInput = ({ className, value, label, disabled, dataset, onCha return (
{label}
-
+
diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js index 6def57537..552b6116c 100644 --- a/src/routes/Settings/Settings.js +++ b/src/routes/Settings/Settings.js @@ -184,20 +184,20 @@ const Settings = () => {
- App Version: {process.env.VERSION} + {`${t('SETTINGS_APP_VERSION')}: ${process.env.VERSION}`}
- Build Version: {process.env.COMMIT_HASH} + {`${t('SETTINGS_BUILD_VERSION')}: ${process.env.COMMIT_HASH}`}
{ streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ? -
Server Version: {streamingServer.settings.content.serverVersion}
+
{`${t('SETTINGS_SERVER_VERSION')}: ${streamingServer.settings.content.serverVersion}`}
: null } { typeof shell?.transport?.props?.shellVersion === 'string' ? -
Shell Version: {shell.transport.props.shellVersion}
+
{`${t('SETTINGS_APP_VERSION')}: ${shell.transport.props.shellVersion}`}
: null } @@ -219,9 +219,9 @@ const Settings = () => { }} />
-
+
- {profile.auth === null ? 'Anonymous user' : profile.auth.user.email} + {profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
{ @@ -273,8 +273,8 @@ const Settings = () => {
-
@@ -310,9 +310,9 @@ const Settings = () => {
-
Trakt Scrobbling
+
{t('SETTINGS_TRAKT')}
- - diff --git a/src/routes/Settings/useStreamingServerSettingsInputs.js b/src/routes/Settings/useStreamingServerSettingsInputs.js index e4bd7e79c..11c60a5af 100644 --- a/src/routes/Settings/useStreamingServerSettingsInputs.js +++ b/src/routes/Settings/useStreamingServerSettingsInputs.js @@ -140,7 +140,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => { return { options: Object.keys(TORRENT_PROFILES) .map((profileName) => ({ - label: profileName, + label: t('TORRENT_PROFILE_' + profileName.replace(' ', '_').toUpperCase()), value: JSON.stringify(TORRENT_PROFILES[profileName]) })) .concat( diff --git a/tests/i18nScan.test.js b/tests/i18nScan.test.js new file mode 100644 index 000000000..5f2964a9a --- /dev/null +++ b/tests/i18nScan.test.js @@ -0,0 +1,107 @@ +const fs = require('fs'); +const path = require('path'); +const recast = require('recast'); +const babelParser = require('@babel/parser'); + +const directoryToScan = './src'; + +function toKey(str) { + return str + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '_') + .slice(0, 40); +} + +function scanFile(filePath, report) { + try { + const code = fs.readFileSync(filePath, 'utf8'); + const ast = babelParser.parse(code, { + sourceType: 'module', + plugins: [ + 'jsx', + 'typescript', + 'classProperties', + 'objectRestSpread', + 'optionalChaining', + 'nullishCoalescingOperator', + ], + errorRecovery: true, + }); + + recast.types.visit(ast, { + visitJSXText(path) { + const text = path.node.value.trim(); + if (text.length > 1 && /\w/.test(text)) { + const loc = path.node.loc?.start || { line: 0 }; + report.push({ + file: filePath, + line: loc.line, + string: text, + key: toKey(text), + }); + } + this.traverse(path); + }, + + visitJSXExpressionContainer(path) { + const expr = path.node.expression; + + if ( + expr.type === 'CallExpression' && + expr.callee.type === 'Identifier' && + expr.callee.name === 't' + ) { + return false; + } + + if (expr.type === 'StringLiteral') { + const parent = path.parentPath.node; + if (parent.type === 'JSXElement') { + const loc = path.node.loc?.start || { line: 0 }; + report.push({ + file: filePath, + line: loc.line, + string: expr.value, + key: toKey(expr.value), + }); + } + } + + this.traverse(path); + } + }); + + } catch (err) { + console.warn(`❌ Skipping ${filePath}: ${err.message}`); + } +} + +function walk(dir, report) { + fs.readdirSync(dir).forEach((file) => { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + walk(fullPath, report); + } else if (/\.(js|jsx|ts|tsx)$/.test(file)) { + // console.log('📄 Scanning file:', fullPath); + scanFile(fullPath, report); + } + }); +} +const report = []; + +walk(directoryToScan, report); + +if (report.length !== 0) { + describe.each(report)('Missing translation key', (entry) => { + it(`should not have "${entry.string}" in ${entry.file} at line ${entry.line}`, () => { + expect(entry.string).toBeFalsy(); + }); + }); +} else { + describe('Missing translation key', () => { + it('No hardcoded strings found', () => { + expect(true).toBe(true); // or just skip + }); + }); +}