diff --git a/package.json b/package.json index 5350bbe1..5bffbfd1 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "crypto-js": "^4.1.1", "fuse.js": "^6.4.6", "hls.js": "^1.0.7", + "i18next": "^22.4.5", + "i18next-browser-languagedetector": "^7.0.1", + "i18next-http-backend": "^2.1.0", "json5": "^2.2.0", "nanoid": "^4.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-i18next": "^12.1.1", "react-router-dom": "^5.2.0", "srt-webvtt": "^2.0.0", "unpacker": "^1.0.1" diff --git a/public/locales/en-GB/translation.json b/public/locales/en-GB/translation.json new file mode 100644 index 00000000..cbc2eba3 --- /dev/null +++ b/public/locales/en-GB/translation.json @@ -0,0 +1,46 @@ +{ + "global": { + "name": "movie-web" + }, + "search": { + "loading": "Fetching your favourite shows...", + "providersFailed": "{{fails}}/{{total}} providers failed!", + "allResults": "That's all we have!", + "noResults": "We couldn't find anything!", + "allFailed": "All providers have failed!", + "headingTitle": "Search results", + "headingLink": "Back to home", + "bookmarks": "Bookmarks", + "continueWatching": "Continue Watching", + "tagline": "Because watching legally is boring", + "title": "What do you want to watch?", + "placeholder": "What do you want to watch?" + }, + "media": { + "invalidUrl": "Your URL may be invalid", + "arrowText": "Go back" + }, + "notFound": { + "backArrow": "Back to home", + "media": { + "title": "Couldn't find that media", + "description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL" + }, + "provider": { + "title": "This provider has been disabled", + "description": "We had issues with the provider or it was too unstable to use, so we had to disable it." + }, + "page": { + "title": "Couldn't find that page", + "description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for." + } + }, + "searchBar": { + "movie": "Movie", + "series": "Series", + "Search": "Search" + }, + "errorBoundary": { + "text": "The app encountered an error and wasn't able to recover, please report it to the" + } +} \ No newline at end of file diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 4cd6a6de..6a5c3ad4 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { MWMediaType, MWQuery } from "@/providers"; +import { useTranslation } from "react-i18next"; import { DropdownButton } from "./buttons/DropdownButton"; import { Icons } from "./Icon"; import { TextInputControl } from "./text-inputs/TextInputControl"; @@ -13,6 +14,8 @@ export interface SearchBarProps { } export function SearchBarInput(props: SearchBarProps) { + const { t } = useTranslation(); + const [dropdownOpen, setDropdownOpen] = useState(false); function setSearch(value: string) { props.onChange( @@ -52,12 +55,12 @@ export function SearchBarInput(props: SearchBarProps) { options={[ { id: MWMediaType.MOVIE, - name: "Movie", + name: t('searchBar.movie'), icon: Icons.FILM, }, { id: MWMediaType.SERIES, - name: "Series", + name: t('searchBar.series'), icon: Icons.CLAPPER_BOARD, }, // { @@ -68,7 +71,7 @@ export function SearchBarInput(props: SearchBarProps) { ]} onClick={() => setDropdownOpen((old) => !old)} > - {props.buttonText || "Search"} + {props.buttonText || t('searchBar.search')} ); diff --git a/src/components/layout/BrandPill.tsx b/src/components/layout/BrandPill.tsx index 842cf8a3..3df0be76 100644 --- a/src/components/layout/BrandPill.tsx +++ b/src/components/layout/BrandPill.tsx @@ -1,16 +1,18 @@ import { Icon, Icons } from "@/components/Icon"; +import { useTranslation } from "react-i18next"; export function BrandPill(props: { clickable?: boolean }) { + const { t } = useTranslation(); + return (
- movie-web + {t('global.name')}
); } diff --git a/src/components/layout/Seasons.tsx b/src/components/layout/Seasons.tsx index 7f510cdb..6dca785a 100644 --- a/src/components/layout/Seasons.tsx +++ b/src/components/layout/Seasons.tsx @@ -14,12 +14,15 @@ import { MWPortableMedia, } from "@/providers"; import { getSeasonDataFromMedia } from "@/providers/methods/seasons"; +import { useTranslation } from "react-i18next"; export interface SeasonsProps { media: MWMedia; } export function LoadingSeasons(props: { error?: boolean }) { + const { t } = useTranslation(); + return (
@@ -34,7 +37,7 @@ export function LoadingSeasons(props: { error?: boolean }) { ) : (
-

Failed to load seasons and episodes

+

{t('seasons.failed')}

)}
@@ -42,6 +45,8 @@ export function LoadingSeasons(props: { error?: boolean }) { } export function Seasons(props: SeasonsProps) { + const { t } = useTranslation(); + const [searchSeasons, loading, error, success] = useLoading( (portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia) ); @@ -70,7 +75,7 @@ export function Seasons(props: SeasonsProps) { const mapSeason = (season: MWMediaSeason) => ({ id: season.id, - name: season.title || `Season ${season.sort}`, + name: season.title || `${t('seasons.season')} ${season.sort}`, }); const options = seasons.seasons.map(mapSeason); diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 00000000..8ab960b1 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,28 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + // load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) + // learn more: https://github.com/i18next/i18next-http-backend + // want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn + .use(Backend) + // detect user language + // learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + // pass the i18n instance to react-i18next. + .use(initReactI18next) + // init i18next + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + fallbackLng: 'en-GB', + + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + } + }); + + +export default i18n; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 7342c4c5..4b233305 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,15 +1,18 @@ -import React from "react"; +import React, { Suspense } from "react"; import ReactDOM from "react-dom"; import { HashRouter } from "react-router-dom"; import "./index.css"; import { ErrorBoundary } from "@/components/layout/ErrorBoundary"; import App from "./App"; +import './i18n'; ReactDOM.render( - + + + , diff --git a/src/providers/list/xemovie/index.ts b/src/providers/list/xemovie/index.ts index 3f6b585b..7f65f026 100644 --- a/src/providers/list/xemovie/index.ts +++ b/src/providers/list/xemovie/index.ts @@ -100,8 +100,7 @@ export const xemovieScraper: MWMediaProvider = { const data = JSON.parse( JSON.stringify( eval( - `(${ - script.textContent.replace("const data = ", "").split("};")[0] + `(${script.textContent.replace("const data = ", "").split("};")[0] }})` ) ) diff --git a/src/views/MediaView.tsx b/src/views/MediaView.tsx index 446d9515..1c121cd2 100644 --- a/src/views/MediaView.tsx +++ b/src/views/MediaView.tsx @@ -29,6 +29,7 @@ import { useBookmarkContext, } from "@/state/bookmark"; import { getWatchedFromPortable, useWatchedContext } from "@/state/watched"; +import { useTranslation } from "react-i18next"; import { NotFoundChecks } from "./notfound/NotFoundChecks"; interface StyledMediaViewProps { @@ -105,6 +106,8 @@ function StyledMediaFooter(props: StyledMediaFooterProps) { } function LoadingMediaFooter(props: { error?: boolean }) { + const { t } = useTranslation(); + return (
@@ -117,7 +120,7 @@ function LoadingMediaFooter(props: { error?: boolean }) { {props.error ? (
-

Your url may be invalid

+

{t('media.invalidUrl')}

) : ( @@ -183,6 +186,7 @@ function MediaViewContent(props: { portable: MWPortableMedia }) { } export function MediaView() { + const { t } = useTranslation(); const mediaPortable: MWPortableMedia | undefined = usePortableMedia(); const reactHistory = useHistory(); @@ -196,7 +200,7 @@ export function MediaView() { : reactHistory.push("/") } direction="left" - linkText="Go back" + linkText={t('media.arrowText')} /> diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index f832a2b2..5d7fa97b 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -18,9 +18,11 @@ import { getIfBookmarkedFromPortable, useBookmarkContext, } from "@/state/bookmark/context"; +import { useTranslation } from "react-i18next"; function SearchLoading() { - return ; + const { t } = useTranslation(); + return ; } function SearchSuffix(props: { @@ -28,6 +30,8 @@ function SearchSuffix(props: { total: number; resultsSize: number; }) { + const { t } = useTranslation(); + const allFailed: boolean = props.fails === props.total; const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH; @@ -43,13 +47,13 @@ function SearchSuffix(props: {
{props.fails > 0 ? (

- {props.fails}/{props.total} providers failed! + {t('search.providersFailed', { fails: props.fails, total: props.total })}

) : null} {props.resultsSize > 0 ? ( -

That's all we have!

+

{t('search.allResults')}

) : ( -

We couldn't find anything!

+

{t('search.noResults')}

)}
) : null} @@ -57,7 +61,7 @@ function SearchSuffix(props: { {/* Error result */} {allFailed ? (
-

All providers have failed!

+

{t('search.allFailed')}

) : null}
@@ -71,6 +75,8 @@ function SearchResultsView({ searchQuery: MWQuery; clear: () => void; }) { + const { t } = useTranslation(); + const [results, setResults] = useState(); const [runSearchQuery, loading, error, success] = useLoading( (query: MWQuery) => SearchProviders(query) @@ -91,9 +97,9 @@ function SearchResultsView({ {/* results */} {success && results?.results.length ? ( clear()} > {results.results.map((v) => ( @@ -124,6 +130,8 @@ function SearchResultsView({ } function ExtraItems() { + const { t } = useTranslation(); + const { getFilteredBookmarks } = useBookmarkContext(); const { getFilteredWatched } = useWatchedContext(); @@ -138,7 +146,7 @@ function ExtraItems() { return (
{bookmarks.length > 0 ? ( - + {bookmarks.map((v) => ( ) : null} {watchedItems.length > 0 ? ( - + {watchedItems.map((v) => ( (false); const [loading, setLoading] = useState(false); const [search, setSearch, setSearchUnFocus] = useSearchQuery(); @@ -195,14 +205,14 @@ export function SearchView() { {/* input section */}
- Because watching legally is boring - What movie do you want to watch? + {t('search.tagline')} + {t('search.title')}
diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx index aee1481b..c797e281 100644 --- a/src/views/notfound/NotFoundView.tsx +++ b/src/views/notfound/NotFoundView.tsx @@ -4,6 +4,7 @@ import { Icons } from "@/components/Icon"; import { Navigation } from "@/components/layout/Navigation"; import { ArrowLink } from "@/components/text/ArrowLink"; import { Title } from "@/components/text/Title"; +import { useTranslation } from "react-i18next"; function NotFoundWrapper(props: { children?: ReactNode }) { return ( @@ -17,52 +18,55 @@ function NotFoundWrapper(props: { children?: ReactNode }) { } export function NotFoundMedia() { + const { t } = useTranslation(); + return (
- Couldn't find that media + {t('notFound.media.title')}

- We couldn't find the media you requested. Either it's been - removed or you tampered with the URL + {t('notFound.media.description')}

- +
); } export function NotFoundProvider() { + const { t } = useTranslation(); + return (
- This provider has been disabled + {t('notFound.provider.title')}

- We had issues with the provider or it was too unstable to use, so we had - to disable it. + {t('notFound.provider.description')}

- +
); } export function NotFoundPage() { + const { t } = useTranslation(); + return ( - Couldn't find that page + {t('notFound.page.title')}

- We looked everywhere: under the bins, in the closet, behind the proxy - but ultimately couldn't find the page you are looking for. + {t('notFound.page.description')}

- +
); } diff --git a/yarn.lock b/yarn.lock index 6a55ed64..b241a993 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.18.9": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6": version "7.20.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.6.tgz#facf4879bfed9b5326326273a64220f099b0fce3" integrity sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA== @@ -680,6 +680,13 @@ core-js-pure@^3.25.1: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.26.1.tgz#653f4d7130c427820dcecd3168b594e8bb095a33" integrity sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ== +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1354,6 +1361,34 @@ hoist-non-react-statics@^3.1.0: dependencies: react-is "^16.7.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + +i18next-browser-languagedetector@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.0.1.tgz#ead34592edc96c6c3a618a51cb57ad027c5b5d87" + integrity sha512-Pa5kFwaczXJAeHE56CHG2aWzFBMJNUNghf0Pm4SwSrEMps/PTKqW90EYWlIvhuYStf3Sn1K0vw+gH3+TLdkH1g== + dependencies: + "@babel/runtime" "^7.19.4" + +i18next-http-backend@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.0.tgz#c521603b864e5334fee21fcf1a57c5adefb15089" + integrity sha512-rTVhhFrpnZJnNvCCdC6RjhFPk0S6mJ2VAix93vbDD19ixlrSJtoNqkk49wvR10PImBSsuGJf35gMQwn2mjer6A== + dependencies: + cross-fetch "3.1.5" + +i18next@^22.4.5: + version "22.4.5" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.4.5.tgz#7324e4946c2facbe743ca25bca8980af05b0a109" + integrity sha512-Kc+Ow0guRetUq+kv02tj0Yof9zveROPBAmJ8UxxNODLVBRSwsM4iD0Gw3BEieOmkWemF6clU3K1fbnCuTqiN2Q== + dependencies: + "@babel/runtime" "^7.20.6" + ignore@^5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.1.tgz#c2b1f76cb999ede1502f3a226a9310fdfe88d46c" @@ -1673,6 +1708,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-releases@^2.0.6: version "2.0.7" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.7.tgz#593edbc7c22860ee4d32d3933cfebdfab0c0e0e5" @@ -1941,6 +1983,14 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-i18next@^12.1.1: + version "12.1.1" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-12.1.1.tgz#2626cdbfe6bcb76ef833861c0184a5c4e5e3c089" + integrity sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA== + dependencies: + "@babel/runtime" "^7.14.5" + html-parse-stringify "^3.0.1" + react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -2251,6 +2301,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" @@ -2342,6 +2397,24 @@ vite@^4.0.1: optionalDependencies: fsevents "~2.3.2" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"