Skip to content

Commit

Permalink
Add language selector (#540)
Browse files Browse the repository at this point in the history
* feat(web): implement language selection

* fix(web): ensure locale cookie synced with URL on browser navigation
  • Loading branch information
sruenwg authored Aug 24, 2024
1 parent a69c72f commit 1c9d7e0
Show file tree
Hide file tree
Showing 14 changed files with 195 additions and 36 deletions.
5 changes: 3 additions & 2 deletions service/vspo-schedule/web/next-i18next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ const path = require("path");

module.exports = {
i18n: {
defaultLocale: "ja",
locales: ["en", "ja"],
defaultLocale: "default",
locales: ["default", "en", "ja"],
localeDetection: false,
},
localePath: path.resolve("./public/locales"),
reloadOnPrerender: process.env.NODE_ENV === "development",
Expand Down
1 change: 1 addition & 0 deletions service/vspo-schedule/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const nextConfig = {
],
},
i18n,
skipMiddlewareUrlNormalize: true,
experimental: {
scrollRestoration: true,
},
Expand Down
2 changes: 1 addition & 1 deletion service/vspo-schedule/web/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"qa": "Contact Us",
"discord": "Discord Bot"
},
"settings": "Settings",
"language": "Language",
"timeZone": "Time zone",
"site-theme": "Theme"
},
Expand Down
2 changes: 1 addition & 1 deletion service/vspo-schedule/web/public/locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"qa": "お問い合わせ",
"discord": "Discord Bot"
},
"settings": "設定",
"language": "言語",
"timeZone": "タイムゾーン",
"site-theme": "背景のテーマ"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MenuItem, TextField } from "@mui/material";
import { useTranslation } from "next-i18next";
import { useLocale } from "@/hooks";

const localeLabels: { [localeCode: string]: string } = {
en: "English",
ja: "日本語",
};

export const LanguageSelector = () => {
const { t } = useTranslation("common");
const { locale, setLocale } = useLocale();

return (
locale !== undefined && (
<TextField
select
size="small"
id="language-select"
label={t("drawer.language")}
value={locale}
onChange={(event) => {
const selectedLocale = event.target.value;
setLocale(selectedLocale);
}}
>
{Object.keys(localeLabels).map((loc) => (
<MenuItem key={loc} value={loc}>
{localeLabels[loc]}
</MenuItem>
))}
</TextField>
)
);
};
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./LanguageSelector";
export * from "./TimeZoneSelector";
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { DrawerIcon } from "../Icon";
import { ThemeToggleButton } from "../Button";
import { useTranslation } from "next-i18next";
import { TimeZoneSelector } from "../Control";
import { LanguageSelector, TimeZoneSelector } from "../Control";
import { useTimeZoneContext } from "@/hooks";
import { Link } from "../Link";

Expand Down Expand Up @@ -222,6 +222,7 @@ export const CustomDrawer: React.FC<DrawerProps> = ({
padding: "20px 12px",
}}
>
<LanguageSelector />
<TimeZoneSelector />
<ThemeToggleButton />
</Box>
Expand Down
11 changes: 2 additions & 9 deletions service/vspo-schedule/web/src/hooks/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { getCookieValue } from "@/lib/utils";

export const useCookie = (key: string, initialValue?: string) => {
const [value, setValue] = useState(initialValue);
Expand All @@ -23,13 +24,5 @@ const getCookie = (key: string) => {
if (typeof document === "undefined") {
return undefined;
}
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const parts = cookie.trim().split("=");
if (parts[0] === key && parts.length >= 2) {
const value = parts.slice(1).join("=");
return decodeURIComponent(value);
}
}
return undefined;
return getCookieValue(key, document.cookie);
};
1 change: 1 addition & 0 deletions service/vspo-schedule/web/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./cookie";
export * from "./locale";
export * from "./time-zone";
export * from "./video-modal";
30 changes: 30 additions & 0 deletions service/vspo-schedule/web/src/hooks/locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { DEFAULT_LOCALE, LOCALE_COOKIE } from "@/lib/Const";
import { useCookie } from "./cookie";
import { useRouter } from "next/router";
import { useEffect } from "react";

export const useLocale = () => {
const router = useRouter();
const [localeCookie, setLocaleCookie] = useCookie(
LOCALE_COOKIE,
DEFAULT_LOCALE,
);

const setLocale = (locale: string) => {
if (locale !== router.locale) {
setLocaleCookie(locale);
router.push(router.asPath, undefined, { scroll: false, locale });
}
};

useEffect(() => {
if (localeCookie !== router.locale) {
setLocaleCookie(router.locale ?? DEFAULT_LOCALE);
}
}, [router.locale]);

return {
locale: router.locale ?? DEFAULT_LOCALE,
setLocale,
};
};
2 changes: 2 additions & 0 deletions service/vspo-schedule/web/src/lib/Const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export const TEMP_TIMESTAMP = "1998-01-01T12:00:00Z";
export const DEFAULT_LOCALE = "ja";
export const DEFAULT_TIME_ZONE = "Asia/Tokyo";

export const LOCALE_COOKIE = "NEXT_LOCALE";

export const TIME_ZONE_COOKIE = "time-zone";
export const TIME_ZONE_HEADER = "x-vercel-ip-timezone";

Expand Down
44 changes: 43 additions & 1 deletion service/vspo-schedule/web/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { Timeframe } from "@/types/timeframe";
import { formatInTimeZone, utcToZonedTime } from "date-fns-tz";
import { enUS, ja } from "date-fns/locale";
import { Locale, getHours } from "date-fns";
import { DEFAULT_LOCALE, TEMP_TIMESTAMP } from "./Const";
import { DEFAULT_LOCALE, TEMP_TIMESTAMP, TIME_ZONE_COOKIE } from "./Const";
import { platforms } from "@/constants/platforms";
import { SSRConfig } from "next-i18next";
import { createInstance as createI18nInstance } from "i18next";
import { SiteNewsTag } from "@/types/site-news";
import { ParsedUrlQuery } from "querystring";
import { convertToUTCDate, getCurrentUTCDate } from "./dayjs";
import { ServerResponse } from "http";

/**
* Group an array of items by a specified key.
Expand Down Expand Up @@ -647,3 +648,44 @@ export const getInitializedI18nInstance = (
i18n.init();
return i18n;
};

/**
* Gets the value of the cookie with the given `cookieName` found in `str`.
* @param cookieName - The name of the desired cookie.
* @param str - The string to search for the cookie in, e.g. `document.cookie`.
* @returns The value of the cookie with the given name, or
* undefined if no such cookie found in `str`.
*/
export const getCookieValue = (cookieName: string, str: string) => {
for (const maybeCookie of str.split(";")) {
const parts = maybeCookie.trim().split("=");
if (parts[0] === cookieName && parts.length >= 2) {
const value = parts.slice(1).join("=");
return decodeURIComponent(value);
}
}
return undefined;
};

/**
* Gets the time zone contained in the given response's set-cookie header.
* @param res - The server response object containing the header.
* @returns The value of the time zone in the set-cookie header, or
* undefined if the set-cookie header does not set a time zone.
*/
export const getSetCookieTimeZone = (res: ServerResponse) => {
const setCookieHeader = res.getHeader("set-cookie");
if (setCookieHeader === undefined || typeof setCookieHeader === "number") {
return undefined;
}
const cookies = Array.isArray(setCookieHeader)
? setCookieHeader
: [setCookieHeader];
for (const cookie of cookies) {
const cookieValue = getCookieValue(TIME_ZONE_COOKIE, cookie);
if (cookieValue !== undefined) {
return cookieValue;
}
}
return undefined;
};
86 changes: 67 additions & 19 deletions service/vspo-schedule/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import {
DEFAULT_LOCALE,
DEFAULT_TIME_ZONE,
LOCALE_COOKIE,
TIME_ZONE_COOKIE,
TIME_ZONE_HEADER,
} from "./lib/Const";

const publicFileRegex = /\.(.*)$/;
const locales = ["en", "ja"];

export const middleware = (req: NextRequest) => {
try {
Expand All @@ -18,27 +21,69 @@ export const middleware = (req: NextRequest) => {
return;
}

// If the client does not have a time zone cookie set (i.e. the request does
// not contain a time zone cookie), set the time zone cookie to be the first
// valid time zone in:
// - x-vercel-ip-timezone header
// - default app time zone
const cookieTimeZone = getCookieTimeZone(req);
if (cookieTimeZone !== undefined) {
return NextResponse.next();
const res = NextResponse.next();
const redirect = setLocale(req, res);
if (redirect) {
return redirect;
}
const headerTimeZone = getHeaderTimeZone(req);
const timeZoneToSet = headerTimeZone ?? DEFAULT_TIME_ZONE;
setCookie(req, TIME_ZONE_COOKIE, timeZoneToSet);
const res = NextResponse.next({ request: req });
setCookie(res, TIME_ZONE_COOKIE, timeZoneToSet);
setTimeZone(req, res);
return res;
} catch (e) {
console.error(e);
return NextResponse.next();
}
};

/**
* Direct response to the first valid locale in:
* - path prefix
* - NEXT_LOCALE cookie
* - Accept-Language header
* - default app locale
* and save the locale to the NEXT_LOCALE cookie.
*/
const setLocale = (req: NextRequest, res: NextResponse) => {
const pathLocale = getPathLocale(req);
if (pathLocale === undefined) {
const locale = getCookieLocale(req)
?? getHeaderLocale(req)
?? DEFAULT_LOCALE;
return NextResponse.redirect(createUrlWithLocale(req, locale));
}
setCookie(res, LOCALE_COOKIE, pathLocale);
return undefined;
};

/**
* Set the time zone cookie to the first valid time zone in:
* - x-vercel-ip-timezone header
* - default app time zone
*/
const setTimeZone = (req: NextRequest, res: NextResponse) => {
const timeZone = getCookieTimeZone(req)
?? getHeaderTimeZone(req)
?? DEFAULT_TIME_ZONE;
setCookie(res, TIME_ZONE_COOKIE, timeZone);
};

const getCookieLocale = (req: NextRequest) => {
return req.cookies.get(LOCALE_COOKIE)?.value;
};

const getPathLocale = (req: NextRequest) => {
const url = new URL(req.url);
const localeCandidate = url.pathname.split("/")[1];
return locales.includes(localeCandidate) ? localeCandidate : undefined;
};

const getHeaderLocale = (req: NextRequest) => {
const acceptLanguageHeader = req.headers.get("accept-language");
const headerLocales = acceptLanguageHeader?.split(",").map((part) => {
return part.trimStart().slice(0, 2);
});
return headerLocales?.find((headerLocale) => locales.includes(headerLocale));
};

const getCookieTimeZone = (req: NextRequest) => {
return req.cookies.get(TIME_ZONE_COOKIE)?.value;
};
Expand All @@ -47,15 +92,18 @@ const getHeaderTimeZone = (req: NextRequest) => {
return req.headers.get(TIME_ZONE_HEADER) ?? undefined;
};

const setCookie = (
reqOrRes: NextRequest | NextResponse,
name: string,
value: string,
) => {
reqOrRes.cookies.set({
const setCookie = (res: NextResponse, name: string, value: string) => {
res.cookies.set({
name,
value,
path: "/",
maxAge: 34560000,
});
};

const createUrlWithLocale = (req: NextRequest, locale: string) => {
return new URL(
`/${locale}${req.nextUrl.pathname}${req.nextUrl.search}`,
req.url,
);
};
8 changes: 6 additions & 2 deletions service/vspo-schedule/web/src/pages/schedule/[status].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatDate,
getInitializedI18nInstance,
getOneWeekRange,
getSetCookieTimeZone,
} from "@/lib/utils";
import { TabContext } from "@mui/lab";
import { ContentLayout } from "@/components/Layout/ContentLayout";
Expand Down Expand Up @@ -151,15 +152,18 @@ const HomePage: NextPageWithLayout<LivestreamsProps> = ({
export const getServerSideProps: GetServerSideProps<
LivestreamsProps,
Params
> = async ({ params, locale = DEFAULT_LOCALE, req }) => {
> = async ({ params, locale = DEFAULT_LOCALE, req, res }) => {
if (!params) {
return {
notFound: true,
};
}

try {
const timeZone = req.cookies[TIME_ZONE_COOKIE] ?? DEFAULT_TIME_ZONE;
const timeZone =
getSetCookieTimeZone(res) ??
req.cookies[TIME_ZONE_COOKIE] ??
DEFAULT_TIME_ZONE;
const isDateStatus = isValidDate(params.status);

// Logic 1: Fetch uniqueLivestreams
Expand Down

0 comments on commit 1c9d7e0

Please sign in to comment.