feat(frontend/api): i18n support

This commit is contained in:
sct
2020-09-17 16:17:41 +00:00
parent 04252f88bb
commit 9131254f33
22 changed files with 929 additions and 118 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useContext } from 'react';
import useSWR from 'swr';
import type { MovieResult, TvResult } from '../../../server/models/Search';
import TitleCard from '../TitleCard';
@@ -6,6 +6,14 @@ import { MediaRequest } from '../../../server/entity/MediaRequest';
import RequestCard from '../TitleCard/RequestCard';
import Slider from '../Slider';
import Link from 'next/link';
import { defineMessages, FormattedMessage } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
const messages = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
});
interface MovieDiscoverResult {
page: number;
@@ -22,11 +30,12 @@ interface TvDiscoverResult {
}
const Discover: React.FC = () => {
const { locale } = useContext(LanguageContext);
const { data: movieData, error: movieError } = useSWR<MovieDiscoverResult>(
'/api/v1/discover/movies'
`/api/v1/discover/movies?language=${locale}`
);
const { data: tvData, error: tvError } = useSWR<TvDiscoverResult>(
'/api/v1/discover/tv'
`/api/v1/discover/tv?language=${locale}`
);
const { data: requests, error: requestError } = useSWR<MediaRequest[]>(
@@ -39,7 +48,9 @@ const Discover: React.FC = () => {
<div className="flex-1 min-w-0">
<Link href="/requests">
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Recent Requests</span>
<span>
<FormattedMessage {...messages.recentrequests} />
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
@@ -74,7 +85,9 @@ const Discover: React.FC = () => {
<div className="flex-1 min-w-0">
<Link href="/discover/movies">
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Popular Movies</span>
<span>
<FormattedMessage {...messages.popularmovies} />
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
@@ -116,7 +129,9 @@ const Discover: React.FC = () => {
<div className="flex-1 min-w-0">
<Link href="/discover/tv">
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Popular TV Shows</span>
<span>
<FormattedMessage {...messages.populartv} />
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"

View File

@@ -0,0 +1,109 @@
import React, { useState, useRef, useContext } from 'react';
import Transition from '../../Transition';
import useClickOutside from '../../../hooks/useClickOutside';
import {
LanguageContext,
AvailableLocales,
} from '../../../context/LanguageContext';
import { FormattedMessage, defineMessages } from 'react-intl';
const messages = defineMessages({
changelanguage: 'Change Language',
});
type AvailableLanguageObject = Record<
string,
{ code: AvailableLocales; display: string }
>;
const availableLanguages: AvailableLanguageObject = {
en: {
code: 'en',
display: 'English',
},
ja: {
code: 'ja',
display: '日本語',
},
};
const LanguagePicker: React.FC = () => {
const dropdownRef = useRef<HTMLDivElement>(null);
const { locale, setLocale } = useContext(LanguageContext);
const [isDropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(dropdownRef, () => setDropdownOpen(false));
return (
<div className="ml-3 relative">
<div>
<button
className="p-1 text-gray-400 rounded-full hover:bg-cool-gray-500 hover:text-white focus:outline-none focus:shadow-outline focus:text-white"
aria-label="Language Picker"
onClick={() => setDropdownOpen(true)}
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M7 2a1 1 0 011 1v1h3a1 1 0 110 2H9.578a18.87 18.87 0 01-1.724 4.78c.29.354.596.696.914 1.026a1 1 0 11-1.44 1.389c-.188-.196-.373-.396-.554-.6a19.098 19.098 0 01-3.107 3.567 1 1 0 01-1.334-1.49 17.087 17.087 0 003.13-3.733 18.992 18.992 0 01-1.487-2.494 1 1 0 111.79-.89c.234.47.489.928.764 1.372.417-.934.752-1.913.997-2.927H3a1 1 0 110-2h3V3a1 1 0 011-1zm6 6a1 1 0 01.894.553l2.991 5.982a.869.869 0 01.02.037l.99 1.98a1 1 0 11-1.79.895L15.383 16h-4.764l-.724 1.447a1 1 0 11-1.788-.894l.99-1.98.019-.038 2.99-5.982A1 1 0 0113 8zm-1.382 6h2.764L13 11.236 11.618 14z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<Transition
show={isDropdownOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div
className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg"
ref={dropdownRef}
>
<div className="py-2 px-2 rounded-md bg-cool-gray-700 shadow-xs">
<div>
<label
htmlFor="language"
className="block text-sm leading-5 font-medium text-cool-gray-300 pb-2"
>
<FormattedMessage {...messages.changelanguage} />
</label>
<select
id="language"
className="mt-1 form-select block w-full pl-3 pr-10 py-2 text-base leading-6 text-white bg-cool-gray-700 border-cool-gray-600 focus:outline-none focus:shadow-outline-indigo focus:border-blue-800 sm:text-sm sm:leading-5"
onChange={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
}
onBlur={(e) =>
setLocale && setLocale(e.target.value as AvailableLocales)
}
>
{(Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
selected={locale === availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</select>
</div>
</div>
</div>
</Transition>
</div>
);
};
export default LanguagePicker;

View File

@@ -3,6 +3,7 @@ import SearchInput from './SearchInput';
import UserDropdown from './UserDropdown';
import Sidebar from './Sidebar';
import Notifications from './Notifications';
import LanguagePicker from './LanguagePicker';
const Layout: React.FC = ({ children }) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
@@ -35,6 +36,7 @@ const Layout: React.FC = ({ children }) => {
<div className="flex-1 px-4 flex justify-between">
<SearchInput />
<div className="ml-4 flex items-center md:ml-6">
<LanguagePicker />
<Notifications />
<UserDropdown />
</div>

View File

@@ -1,4 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useContext } from 'react';
import {
FormattedMessage,
defineMessages,
FormattedNumber,
FormattedDate,
} from 'react-intl';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import useSWR from 'swr';
import { useRouter } from 'next/router';
@@ -12,6 +18,19 @@ import Link from 'next/link';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
import PersonCard from '../PersonCard';
import { LanguageContext } from '../../context/LanguageContext';
const messages = defineMessages({
releasedate: 'Release Date',
userrating: 'User Rating',
status: 'Status',
revenue: 'Revenue',
budget: 'Budget',
originallanguage: 'Original Language',
overview: 'Overview',
runtime: '{minutes} minutes',
cast: 'Cast',
});
interface MovieDetailsProps {
movie?: MovieDetailsType;
@@ -33,20 +52,21 @@ enum MediaRequestStatus {
const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const router = useRouter();
const { locale } = useContext(LanguageContext);
const { addToast } = useToasts();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}`,
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
{
initialData: movie,
}
);
const { data: recommended, error: recommendedError } = useSWR<SearchResult>(
`/api/v1/movie/${router.query.movieId}/recommendations`
`/api/v1/movie/${router.query.movieId}/recommendations?language=${locale}`
);
const { data: similar, error: similarError } = useSWR<SearchResult>(
`/api/v1/movie/${router.query.movieId}/similar`
`/api/v1/movie/${router.query.movieId}/similar?language=${locale}`
);
const request = async () => {
@@ -110,7 +130,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded shadow md:shadow-2xl w-32 md:w-52"
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
@@ -119,7 +139,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
<h1 className="text-2xl md:text-4xl">{data.title}</h1>
<span className="text-xs md:text-base mt-1 md:mt-0">
{data.runtime} minutes | {data.genres.map((g) => g.name).join(', ')}
<FormattedMessage
{...messages.runtime}
values={{ minutes: data.runtime }}
/>{' '}
| {data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex-1 flex justify-end mt-4 md:mt-0">
@@ -249,31 +273,70 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">Overview</h2>
<h2 className="text-xl md:text-2xl">
<FormattedMessage {...messages.overview} />
</h2>
<p className="pt-2 text-sm md:text-base">{data.overview}</p>
</div>
<div className="w-full md:w-80 mt-8 md:mt-0">
<div className="bg-cool-gray-900 rounded-lg shadow border border-cool-gray-800">
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">Status</span>
<span className="text-sm">
<FormattedMessage {...messages.userrating} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
{data.voteAverage}/10
</span>
</div>
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.releasedate} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
<FormattedDate
value={new Date(data.releaseDate)}
year="numeric"
month="long"
day="numeric"
/>
</span>
</div>
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.status} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
{data.status}
</span>
</div>
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">Revenue</span>
<span className="text-sm">
<FormattedMessage {...messages.revenue} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
{data.revenue}
<FormattedNumber
currency="USD"
style="currency"
value={data.revenue}
/>
</span>
</div>
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">Budget</span>
<span className="text-sm">
<FormattedMessage {...messages.budget} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
{data.budget}
<FormattedNumber
currency="USD"
style="currency"
value={data.budget}
/>
</span>
</div>
<div className="flex px-4 py-2 border-b border-cool-gray-800 last:border-b-0">
<span className="text-sm">Original Language</span>
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-right text-cool-gray-400 text-sm">
{data.originalLanguage}
</span>
@@ -285,7 +348,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<div className="flex-1 min-w-0">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="inline-flex text-xl leading-7 text-cool-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>Cast</span>
<span>
<FormattedMessage {...messages.cast} />
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { useContext } from 'react';
import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv';
import TitleCard from '.';
import { LanguageContext } from '../../context/LanguageContext';
interface TmdbTitleCardProps {
tmdbId: number;
@@ -14,9 +15,12 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
};
const RequestCard: React.FC<TmdbTitleCardProps> = ({ tmdbId, type }) => {
const { locale } = useContext(LanguageContext);
const url =
type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(url);
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
`${url}?language=${locale}`
);
if (!title && !error) {
return <TitleCard.Placeholder />;

View File

@@ -0,0 +1,15 @@
import React, { ReactNode } from 'react';
export type AvailableLocales = 'en' | 'ja';
interface LanguageContextProps {
locale: AvailableLocales;
children: (locale: string) => ReactNode;
setLocale?: React.Dispatch<React.SetStateAction<AvailableLocales>>;
}
export const LanguageContext = React.createContext<
Omit<LanguageContextProps, 'children'>
>({
locale: 'en',
});

15
src/i18n/locale/en.json Normal file
View File

@@ -0,0 +1,15 @@
{
"components.Discover.popularmovies": "Popular Movies",
"components.Discover.populartv": "Popular Series",
"components.Discover.recentrequests": "Recent Requests",
"components.Layout.LanguagePicker.changelanguage": "Change Language",
"components.MovieDetails.budget": "Budget",
"components.MovieDetails.cast": "Cast",
"components.MovieDetails.originallanguage": "Original Language",
"components.MovieDetails.overview": "Overview",
"components.MovieDetails.releasedate": "Release Date",
"components.MovieDetails.revenue": "Revenue",
"components.MovieDetails.runtime": "{minutes} minutes",
"components.MovieDetails.status": "Status",
"components.MovieDetails.userrating": "User Rating"
}

15
src/i18n/locale/ja.json Normal file
View File

@@ -0,0 +1,15 @@
{
"components.Discover.popularmovies": "人気映画",
"components.Discover.populartv": "人気テレビ番組",
"components.Discover.recentrequests": "最近のリクエスト",
"components.Layout.LanguagePicker.changelanguage": "言語",
"components.MovieDetails.budget": "興行収入",
"components.MovieDetails.cast": "キャスト",
"components.MovieDetails.originallanguage": "言語",
"components.MovieDetails.overview": "ストーリー",
"components.MovieDetails.releasedate": "公開日",
"components.MovieDetails.revenue": "製作費",
"components.MovieDetails.runtime": "{minutes}分",
"components.MovieDetails.status": "状態",
"components.MovieDetails.userrating": "ユーザー評価"
}

View File

@@ -1,82 +1,136 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import '../styles/globals.css';
import App, { AppInitialProps } from 'next/app';
import App, { AppInitialProps, AppProps } from 'next/app';
import { SWRConfig } from 'swr';
import { ToastProvider } from 'react-toast-notifications';
import { parseCookies, setCookie } from 'nookies';
import Layout from '../components/Layout';
import { UserContext } from '../context/UserContext';
import axios from 'axios';
import { User } from '../hooks/useUser';
import { IntlProvider } from 'react-intl';
import { LanguageContext, AvailableLocales } from '../context/LanguageContext';
const loadLocaleData = (locale: string) => {
switch (locale) {
case 'ja':
return import('../i18n/locale/ja.json');
default:
return import('../i18n/locale/en.json');
}
};
// Custom types so we can correctly type our GetInitialProps function
// with our combined user prop
// This is specific to _app.tsx. Other pages will not need to do this!
type NextAppComponentType = typeof App;
type GetInitialPropsFn = NextAppComponentType['getInitialProps'];
type MessagesType = Record<string, any>;
interface AppProps {
interface ExtendedAppProps extends AppProps {
user: User;
messages: MessagesType;
locale: AvailableLocales;
}
class CoreApp extends App<AppProps> {
public static getInitialProps: GetInitialPropsFn = async (initialProps) => {
// Run the default getInitialProps for the main nextjs initialProps
const appInitialProps: AppInitialProps = await App.getInitialProps(
initialProps
);
const { ctx, router } = initialProps;
let user = undefined;
if (ctx.res) {
try {
// Attempt to get the user by running a request to the local api
const response = await axios.get<User>(
`http://localhost:${process.env.PORT || 3000}/api/v1/auth/me`,
{ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined }
);
user = response.data;
} catch (e) {
// If there is no user, and ctx.res is set (to check if we are on the server side)
// _AND_ we are not already on the login or setup route, redirect to /login with a 307
// before anything actually renders
if (!router.pathname.match(/(login|setup)/)) {
ctx.res.writeHead(307, {
Location: '/login',
});
ctx.res.end();
}
}
}
if (typeof window === 'undefined') {
global.Intl = require('intl');
}
return { ...appInitialProps, user };
};
const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
Component,
pageProps,
router,
user,
messages,
locale,
}: ExtendedAppProps) => {
let component: React.ReactNode;
const [loadedMessages, setMessages] = useState<MessagesType>(messages);
const [currentLocale, setLocale] = useState<AvailableLocales>(locale);
public render(): JSX.Element {
const { Component, pageProps, router, user } = this.props;
useEffect(() => {
loadLocaleData(currentLocale).then(setMessages);
setCookie(null, 'locale', currentLocale, { path: '/' });
}, [currentLocale]);
let component: React.ReactNode;
if (router.asPath === '/login') {
component = <Component {...pageProps} />;
} else {
component = (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
return (
<SWRConfig
value={{
fetcher: (url) => axios.get(url).then((res) => res.data),
}}
>
<ToastProvider>
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</SWRConfig>
if (router.asPath === '/login') {
component = <Component {...pageProps} />;
} else {
component = (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
}
return (
<SWRConfig
value={{
fetcher: (url) => axios.get(url).then((res) => res.data),
}}
>
<LanguageContext.Provider value={{ locale: currentLocale, setLocale }}>
<IntlProvider
locale={currentLocale}
defaultLocale="en"
messages={loadedMessages}
>
<ToastProvider>
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>
);
};
CoreApp.getInitialProps = async (initialProps) => {
// Run the default getInitialProps for the main nextjs initialProps
const appInitialProps: AppInitialProps = await App.getInitialProps(
initialProps
);
const { ctx, router } = initialProps;
let user = undefined;
let locale = 'en';
if (ctx.res) {
const cookies = parseCookies(ctx);
if (cookies.locale) {
locale = cookies.locale;
}
try {
// Attempt to get the user by running a request to the local api
const response = await axios.get<User>(
`http://localhost:${process.env.PORT || 3000}/api/v1/auth/me`,
{ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined }
);
user = response.data;
if (router.pathname.match(/login/)) {
ctx.res.writeHead(307, {
Location: '/',
});
ctx.res.end();
}
} catch (e) {
// If there is no user, and ctx.res is set (to check if we are on the server side)
// _AND_ we are not already on the login or setup route, redirect to /login with a 307
// before anything actually renders
if (!router.pathname.match(/(login|setup)/)) {
ctx.res.writeHead(307, {
Location: '/login',
});
ctx.res.end();
}
}
}
const messages = await loadLocaleData(locale);
return { ...appInitialProps, user, messages, locale };
};
export default CoreApp;

View File

@@ -3,6 +3,7 @@ import { NextPage } from 'next';
import type { MovieDetails as MovieDetailsType } from '../../../../server/models/Movie';
import MovieDetails from '../../../components/MovieDetails';
import axios from 'axios';
import { parseCookies } from 'nookies';
interface MoviePageProps {
movie?: MovieDetailsType;
@@ -14,10 +15,11 @@ const MoviePage: NextPage<MoviePageProps> = ({ movie }) => {
MoviePage.getInitialProps = async (ctx) => {
if (ctx.req) {
const cookies = parseCookies(ctx);
const response = await axios.get<MovieDetailsType>(
`http://localhost:${process.env.PORT || 3000}/api/v1/movie/${
ctx.query.movieId
}`,
}?language=${cookies.locale}`,
{ headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined }
);

View File

@@ -1,13 +0,0 @@
import React from 'react';
import { NextPage } from 'next';
import PersonCard from '../components/PersonCard';
const PlexText: NextPage = () => {
return (
<div>
<PersonCard />
</div>
);
};
export default PlexText;

11
src/types/react-intl-auto.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { MessageDescriptor } from 'react-intl';
declare module 'react-intl' {
interface ExtractableMessage {
[key: string]: string;
}
export function defineMessages<T extends ExtractableMessage>(
messages: T
): { [K in keyof T]: MessageDescriptor };
}