I18n's the i18n language dropdown & weekday using Intl (#955)

* I18n's the i18n language dropdown & weekday using Intl

* Some type fixes

* Trigger locale changes instantly (#958)

* Trigger locale changes instantly

* Restored types

* Capitalize languages across the board

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Alex van Andel 2021-10-15 12:32:09 +01:00 committed by GitHub
parent b5e176a87e
commit ce8e9c126b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 279 additions and 319 deletions

View file

@ -44,31 +44,3 @@ export const getOrSetUserLocaleFromHeaders = async (req: IncomingMessage): Promi
return preferredLocale; return preferredLocale;
}; };
interface localeType {
[locale: string]: string;
}
export const localeLabels: localeType = {
en: "English",
fr: "French",
it: "Italian",
ru: "Russian",
es: "Spanish",
de: "German",
pt: "Portuguese",
ro: "Romanian",
nl: "Dutch",
"pt-BR": "Portuguese (Brazilian)",
"es-419": "Spanish, Latin America",
ko: "Korean",
};
export type OptionType = {
value: string;
label: string;
};
export const localeOptions: OptionType[] = i18n.locales.map((locale) => {
return { value: locale, label: localeLabels[locale] };
});

8
lib/core/i18n/weekday.ts Normal file
View file

@ -0,0 +1,8 @@
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
export function weekdayNames(locale: string | string[], weekStart = 0, type: "short" | "long" = "long") {
return Array.from(Array(7).keys()).map((d) => nameOfDay(locale, d + weekStart, type));
}
export function nameOfDay(locale: string | string[], day: number, type: "short" | "long" = "long") {
return new Intl.DateTimeFormat(locale, { weekday: type }).format(new Date(1970, 0, day + 4));
}

View file

@ -1,18 +1,15 @@
import { InformationCircleIcon } from "@heroicons/react/outline"; import { InformationCircleIcon } from "@heroicons/react/outline";
import crypto from "crypto"; import crypto from "crypto";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { RefObject, useEffect, useRef, useState } from "react"; import { i18n } from "next-i18next.config";
import Select from "react-select"; import { ComponentProps, RefObject, useEffect, useRef, useState } from "react";
import Select, { OptionTypeBase } from "react-select";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import { QueryCell } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull"; import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth"; import { getSession } from "@lib/auth";
import { import { nameOfDay } from "@lib/core/i18n/weekday";
getOrSetUserLocaleFromHeaders,
localeLabels,
localeOptions,
OptionType,
} from "@lib/core/i18n/i18n.utils";
import { useLocale } from "@lib/hooks/useLocale"; import { useLocale } from "@lib/hooks/useLocale";
import { isBrandingHidden } from "@lib/isBrandingHidden"; import { isBrandingHidden } from "@lib/isBrandingHidden";
import showToast from "@lib/notification"; import showToast from "@lib/notification";
@ -20,7 +17,7 @@ import prisma from "@lib/prisma";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
import { inferSSRProps } from "@lib/types/inferSSRProps"; import { inferSSRProps } from "@lib/types/inferSSRProps";
import { DialogClose, Dialog, DialogContent } from "@components/Dialog"; import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
import ImageUploader from "@components/ImageUploader"; import ImageUploader from "@components/ImageUploader";
import SettingsShell from "@components/SettingsShell"; import SettingsShell from "@components/SettingsShell";
import Shell from "@components/Shell"; import Shell from "@components/Shell";
@ -31,6 +28,14 @@ import Button from "@components/ui/Button";
import { UsernameInput } from "@components/ui/UsernameInput"; import { UsernameInput } from "@components/ui/UsernameInput";
type Props = inferSSRProps<typeof getServerSideProps>; type Props = inferSSRProps<typeof getServerSideProps>;
const getLocaleOptions = (displayLocale: string | string[]): OptionTypeBase[] => {
return i18n.locales.map((locale) => ({
value: locale,
label: new Intl.DisplayNames(displayLocale, { type: "language" }).of(locale),
}));
};
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) { function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
const { t } = useLocale(); const { t } = useLocale();
const [modelOpen, setModalOpen] = useState(false); const [modelOpen, setModalOpen] = useState(false);
@ -58,12 +63,12 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
/> />
<Dialog open={modelOpen}> <Dialog open={modelOpen}>
<DialogContent> <DialogContent>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100 mb-4"> <div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 bg-yellow-100 rounded-full">
<InformationCircleIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" /> <InformationCircleIcon className="w-6 h-6 text-yellow-400" aria-hidden="true" />
</div> </div>
<div className="sm:flex sm:items-start mb-4"> <div className="mb-4 sm:flex sm:items-start">
<div className="mt-3 sm:mt-0 sm:text-left"> <div className="mt-3 sm:mt-0 sm:text-left">
<h3 className="font-cal text-lg leading-6 font-bold text-gray-900" id="modal-title"> <h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
{t("only_available_on_pro_plan")} {t("only_available_on_pro_plan")}
</h3> </h3>
</div> </div>
@ -83,7 +88,7 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2"> <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-x-2">
<DialogClose asChild> <DialogClose asChild>
<Button <Button
className="btn-wide btn-primary text-center table-cell" className="table-cell text-center btn-wide btn-primary"
onClick={() => setModalOpen(false)}> onClick={() => setModalOpen(false)}>
{t("dismiss")} {t("dismiss")}
</Button> </Button>
@ -95,9 +100,25 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
); );
} }
export default function Settings(props: Props) { function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
const utils = trpc.useContext();
const { t } = useLocale(); const { t } = useLocale();
const mutation = trpc.useMutation("viewer.updateProfile"); const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: () => {
showToast(t("your_user_profile_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
},
onError: (err) => {
setHasErrors(true);
setErrorMessage(err.message);
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
},
async onSettled() {
await utils.invalidateQueries(["viewer.i18n"]);
},
});
const localeOptions = getLocaleOptions(props.localeProp);
const themeOptions = [ const themeOptions = [
{ value: "light", label: t("light") }, { value: "light", label: t("light") },
@ -109,15 +130,16 @@ export default function Settings(props: Props) {
const descriptionRef = useRef<HTMLTextAreaElement>(null!); const descriptionRef = useRef<HTMLTextAreaElement>(null!);
const avatarRef = useRef<HTMLInputElement>(null!); const avatarRef = useRef<HTMLInputElement>(null!);
const hideBrandingRef = useRef<HTMLInputElement>(null!); const hideBrandingRef = useRef<HTMLInputElement>(null!);
const [selectedTheme, setSelectedTheme] = useState<undefined | { value: string; label: string }>(undefined); const [selectedTheme, setSelectedTheme] = useState<OptionTypeBase>();
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone }); const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ const [selectedWeekStartDay, setSelectedWeekStartDay] = useState<OptionTypeBase>({
value: props.user.weekStart, value: props.user.weekStart,
label: "", label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
}); });
const [selectedLanguage, setSelectedLanguage] = useState<OptionType>({
const [selectedLanguage, setSelectedLanguage] = useState<OptionTypeBase>({
value: props.localeProp, value: props.localeProp,
label: props.localeLabels[props.localeProp], label: localeOptions.find((option) => option.value === props.localeProp)?.label,
}); });
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || ""); const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
const [hasErrors, setHasErrors] = useState(false); const [hasErrors, setHasErrors] = useState(false);
@ -127,8 +149,6 @@ export default function Settings(props: Props) {
setSelectedTheme( setSelectedTheme(
props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : undefined props.user.theme ? themeOptions.find((theme) => theme.value === props.user.theme) : undefined
); );
setSelectedWeekStartDay({ value: props.user.weekStart, label: props.user.weekStart });
setSelectedLanguage({ value: props.localeProp, label: props.localeLabels[props.localeProp] });
}, []); }, []);
async function updateProfileHandler(event) { async function updateProfileHandler(event) {
@ -145,8 +165,7 @@ export default function Settings(props: Props) {
// TODO: Add validation // TODO: Add validation
await mutation mutation.mutate({
.mutateAsync({
username: enteredUsername, username: enteredUsername,
name: enteredName, name: enteredName,
bio: enteredDescription, bio: enteredDescription,
@ -156,28 +175,17 @@ export default function Settings(props: Props) {
hideBranding: enteredHideBranding, hideBranding: enteredHideBranding,
theme: asStringOrNull(selectedTheme?.value), theme: asStringOrNull(selectedTheme?.value),
locale: enteredLanguage, locale: enteredLanguage,
})
.then(() => {
showToast(t("your_user_profile_updated_successfully"), "success");
setHasErrors(false); // dismiss any open errors
})
.catch((err) => {
setHasErrors(true);
setErrorMessage(err.message);
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
}); });
} }
return ( return (
<Shell heading={t("profile")} subtitle={t("edit_profile_info_description")}>
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}> <form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
{hasErrors && <Alert severity="error" title={errorMessage} />} {hasErrors && <Alert severity="error" title={errorMessage} />}
<div className="py-6 lg:pb-8"> <div className="py-6 lg:pb-8">
<div className="flex flex-col lg:flex-row"> <div className="flex flex-col lg:flex-row">
<div className="flex-grow space-y-6"> <div className="flex-grow space-y-6">
<div className="block sm:flex"> <div className="block sm:flex">
<div className="w-full sm:w-1/2 sm:mr-2 mb-6"> <div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<UsernameInput ref={usernameRef} defaultValue={props.user.username} /> <UsernameInput ref={usernameRef} defaultValue={props.user.username} />
</div> </div>
<div className="w-full sm:w-1/2 sm:ml-2"> <div className="w-full sm:w-1/2 sm:ml-2">
@ -192,14 +200,14 @@ export default function Settings(props: Props) {
autoComplete="given-name" autoComplete="given-name"
placeholder={t("your_name")} placeholder={t("your_name")}
required required
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.user.name} defaultValue={props.user.name}
/> />
</div> </div>
</div> </div>
<div className="block sm:flex"> <div className="block sm:flex">
<div className="w-full sm:w-1/2 sm:mr-2 mb-6"> <div className="w-full mb-6 sm:w-1/2 sm:mr-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700"> <label htmlFor="email" className="block text-sm font-medium text-gray-700">
{t("email")} {t("email")}
</label> </label>
@ -209,7 +217,7 @@ export default function Settings(props: Props) {
id="email" id="email"
placeholder={t("your_email")} placeholder={t("your_email")}
disabled disabled
className="mt-1 block w-full py-2 px-3 text-gray-500 border border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm" className="block w-full px-3 py-2 mt-1 text-gray-500 border border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm"
defaultValue={props.user.email} defaultValue={props.user.email}
/> />
<p className="mt-2 text-sm text-gray-500" id="email-description"> <p className="mt-2 text-sm text-gray-500" id="email-description">
@ -232,15 +240,15 @@ export default function Settings(props: Props) {
name="about" name="about"
placeholder={t("little_something_about")} placeholder={t("little_something_about")}
rows={3} rows={3}
defaultValue={props.user.bio} defaultValue={props.user.bio || undefined}
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"></textarea> className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
</div> </div>
</div> </div>
<div> <div>
<div className="mt-1 flex"> <div className="flex mt-1">
<Avatar <Avatar
displayName={props.user.name} displayName={props.user.name}
className="relative rounded-full w-10 h-10" className="relative w-10 h-10 rounded-full"
gravatarFallbackMd5={props.user.emailMd5} gravatarFallbackMd5={props.user.emailMd5}
imageSrc={imageSrc} imageSrc={imageSrc}
/> />
@ -250,7 +258,7 @@ export default function Settings(props: Props) {
name="avatar" name="avatar"
id="avatar" id="avatar"
placeholder="URL" placeholder="URL"
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm" className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={imageSrc} defaultValue={imageSrc}
/> />
<ImageUploader <ImageUploader
@ -284,8 +292,8 @@ export default function Settings(props: Props) {
value={selectedLanguage || props.localeProp} value={selectedLanguage || props.localeProp}
onChange={setSelectedLanguage} onChange={setSelectedLanguage}
classNamePrefix="react-select" classNamePrefix="react-select"
className="react-select-container border border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm" className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
options={props.localeOptions} options={localeOptions}
/> />
</div> </div>
</div> </div>
@ -299,7 +307,7 @@ export default function Settings(props: Props) {
value={selectedTimeZone} value={selectedTimeZone}
onChange={setSelectedTimeZone} onChange={setSelectedTimeZone}
classNamePrefix="react-select" classNamePrefix="react-select"
className="react-select-container border border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm" className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
/> />
</div> </div>
</div> </div>
@ -313,10 +321,10 @@ export default function Settings(props: Props) {
value={selectedWeekStartDay} value={selectedWeekStartDay}
onChange={setSelectedWeekStartDay} onChange={setSelectedWeekStartDay}
classNamePrefix="react-select" classNamePrefix="react-select"
className="react-select-container border border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm" className="block w-full mt-1 capitalize border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
options={[ options={[
{ value: "Sunday", label: t("sunday") }, { value: "Sunday", label: nameOfDay(props.localeProp, 0) },
{ value: "Monday", label: t("monday") }, { value: "Monday", label: nameOfDay(props.localeProp, 1) },
]} ]}
/> />
</div> </div>
@ -336,7 +344,7 @@ export default function Settings(props: Props) {
options={themeOptions} options={themeOptions}
/> />
</div> </div>
<div className="mt-8 relative flex items-start"> <div className="relative flex items-start mt-8">
<div className="flex items-center h-5"> <div className="flex items-center h-5">
<input <input
id="theme-adjust-os" id="theme-adjust-os"
@ -344,7 +352,7 @@ export default function Settings(props: Props) {
type="checkbox" type="checkbox"
onChange={(e) => setSelectedTheme(e.target.checked ? null : themeOptions[0])} onChange={(e) => setSelectedTheme(e.target.checked ? null : themeOptions[0])}
checked={!selectedTheme} checked={!selectedTheme}
className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm" className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
/> />
</div> </div>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
@ -369,50 +377,27 @@ export default function Settings(props: Props) {
</div> </div>
</div> </div>
</div> </div>
{/*<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
<p className="mb-2 text-sm font-medium text-gray-700" aria-hidden="true">
Photo
</p>
<div className="mt-1 lg:hidden">
<div className="flex items-center">
<div
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
aria-hidden="true">
<Avatar user={props.user} className="rounded-full h-full w-full" />
</div>
</div>
</div>
<div className="hidden relative rounded-full overflow-hidden lg:block">
<Avatar
user={props.user}
className="relative rounded-full w-40 h-40"
fallback={<div className="relative bg-neutral-900 rounded-full w-40 h-40"></div>}
/>
</div>
<div className="mt-4">
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">
Avatar URL
</label>
<input
ref={avatarRef}
type="text"
name="avatar"
id="avatar"
placeholder="URL"
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.user.avatar}
/>
</div>
</div>*/}
</div> </div>
<hr className="mt-8" /> <hr className="mt-8" />
<div className="py-4 flex justify-end"> <div className="flex justify-end py-4">
<Button type="submit">{t("save")}</Button> <Button type="submit">{t("save")}</Button>
</div> </div>
</div> </div>
</form> </form>
);
}
export default function Settings(props: Props) {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.i18n"]);
return (
<Shell heading={t("profile")} subtitle={t("edit_profile_info_description")}>
<SettingsShell>
<QueryCell
query={query}
success={({ data }) => <SettingsView {...props} localeProp={data.locale} />}
/>
</SettingsShell> </SettingsShell>
</Shell> </Shell>
); );
@ -420,7 +405,6 @@ export default function Settings(props: Props) {
export const getServerSideProps = async (context: GetServerSidePropsContext) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context); const session = await getSession(context);
const locale = await getOrSetUserLocaleFromHeaders(context.req);
if (!session?.user?.id) { if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } }; return { redirect: { permanent: false, destination: "/auth/login" } };
@ -451,10 +435,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { return {
props: { props: {
session,
localeProp: locale,
localeOptions,
localeLabels,
user: { user: {
...user, ...user,
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),