Translate user page

This commit is contained in:
Kalle
2022-07-17 13:38:57 +03:00
parent 3e64c23e61
commit 4bc7b3025b
9 changed files with 116 additions and 31 deletions

View File

@@ -6,16 +6,17 @@ import { Badge } from "~/components/Badge";
import { LinkButton } from "~/components/Button";
import { Redirect } from "~/components/Redirect";
import { db } from "~/db";
import type {
ManagersByBadgeId,
OwnersByBadgeId,
import {
type ManagersByBadgeId,
type OwnersByBadgeId,
} from "~/db/models/badges.server";
import type { Badge as BadgeDBType } from "~/db/types";
import { type Badge as BadgeDBType } from "~/db/types";
import { useUser } from "~/modules/auth";
import { canEditBadgeOwners } from "~/permissions";
import { discordFullName } from "~/utils/strings";
import { BADGES_PAGE } from "~/utils/urls";
import type { BadgesLoaderData } from "../badges";
import { type BadgesLoaderData } from "../badges";
import { useTranslation, type TFunction } from "react-i18next";
export interface BadgeDetailsContext {
badgeName: string;
@@ -44,6 +45,7 @@ export default function BadgeDetailsPage() {
const { badges } = parentRoute!.data as BadgesLoaderData;
const params = useParams();
const data = useLoaderData<BadgeDetailsLoaderData>();
const { t } = useTranslation("badges");
const badge = badges.find((b) => b.id === Number(params["id"]));
if (!badge) return <Redirect to={BADGES_PAGE} />;
@@ -55,7 +57,9 @@ export default function BadgeDetailsPage() {
<Outlet context={context} />
<Badge badge={badge} isAnimated size={200} />
<div>
<div className="badges__explanation">{badgeExplanationText(badge)}</div>
<div className="badges__explanation">
{badgeExplanationText(t, badge)}
</div>
<div
className={clsx("badges__managers", {
invisible: data.managers.length === 0,
@@ -90,14 +94,13 @@ export default function BadgeDetailsPage() {
}
export function badgeExplanationText(
t: TFunction<"badges", undefined>,
badge: Pick<BadgeDBType, "displayName" | "code"> & { count?: number }
) {
if (badge.code === "patreon") return "Supporter of sendou.ink on Patreon";
if (badge.code === "patreon") return t("patreon");
if (badge.code === "patreon_plus") {
return "Supporter+ of sendou.ink on Patreon";
return t("patreon+");
}
const countString =
badge.count && badge.count > 1 ? ` (x${badge.count})` : "";
return `Awarded for winning ${badge.displayName}${countString}`;
return t("tournament", { count: badge.count, tournament: badge.displayName });
}

View File

@@ -2,6 +2,7 @@ import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { countries } from "countries-list";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
@@ -9,6 +10,8 @@ import { db } from "~/db";
import type { CountsByUserId } from "~/db/models/badges.server";
import type { User } from "~/db/types";
import { useUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import { translatedCountry } from "~/utils/i18n.server";
import { notFoundIfFalsy } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { discordFullName } from "~/utils/strings";
@@ -19,6 +22,10 @@ export const meta: MetaFunction = ({ data }: { data: UserPageLoaderData }) => {
};
};
export const handle = {
i18n: "user",
};
export const userParamsSchema = z.object({ identifier: z.string() });
export type UserPageLoaderData = Pick<
@@ -33,11 +40,12 @@ export type UserPageLoaderData = Pick<
| "twitter"
| "bio"
> & {
country?: { name: string; emoji: string; code: string };
country?: { emoji: string; code: string; name: string };
badges: CountsByUserId;
};
export const loader: LoaderFunction = ({ params }) => {
export const loader: LoaderFunction = async ({ request, params }) => {
const locale = await i18next.getLocale(request);
const { identifier } = userParamsSchema.parse(params);
const user = notFoundIfFalsy(db.users.findByIdentifier(identifier));
@@ -58,9 +66,13 @@ export const loader: LoaderFunction = ({ params }) => {
country:
countryObj && user.country
? {
name: countryObj.name,
emoji: countryObj.emoji,
code: user.country,
name:
translatedCountry({
language: locale,
countryCode: user.country,
}) ?? countryObj.name,
}
: undefined,
badges: db.badges.countsByUserId(user.id),
@@ -70,6 +82,7 @@ export const loader: LoaderFunction = ({ params }) => {
export default function UserPageLayout() {
const data = useLoaderData<UserPageLoaderData>();
const user = useUser();
const { t } = useTranslation();
const isOwnPage = data.id === user?.id;
@@ -77,11 +90,11 @@ export default function UserPageLayout() {
<>
<SubNav>
<SubNavLink to="" data-cy="profile-page-link">
Profile
{t("header.profile")}
</SubNavLink>
{isOwnPage ? (
<SubNavLink to="edit" data-cy="edit-page-link">
Edit
{t("actions.edit")}
</SubNavLink>
) : null}
</SubNav>

View File

@@ -1,19 +1,31 @@
import type { ActionFunction, LinksFunction } from "@remix-run/node";
import { Form, useMatches, useTransition } from "@remix-run/react";
import {
type ActionFunction,
type LinksFunction,
type LoaderArgs,
} from "@remix-run/node";
import {
Form,
useLoaderData,
useMatches,
useTransition,
} from "@remix-run/react";
import { countries } from "countries-list";
import * as React from "react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { Label } from "~/components/Label";
import { USER_BIO_MAX_LENGTH } from "~/constants";
import { db } from "~/db";
import type { User } from "~/db/types";
import { type User } from "~/db/types";
import { requireUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import styles from "~/styles/u-edit.css";
import { translatedCountry } from "~/utils/i18n.server";
import { parseRequestFormData } from "~/utils/remix";
import { falsyToNull } from "~/utils/zod";
import type { UserPageLoaderData } from "../u.$identifier";
import { type UserPageLoaderData } from "../u.$identifier";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -47,45 +59,66 @@ export const action: ActionFunction = async ({ request }) => {
return null;
};
export const loader = async ({ request }: LoaderArgs) => {
const locale = await i18next.getLocale(request);
return {
countries: Object.entries(countries)
.map(([code, country]) => ({
code,
emoji: country.emoji,
name:
translatedCountry({
countryCode: code,
language: locale,
}) ?? country.name,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
};
};
export default function UserEditPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "user"]);
const [, parentRoute] = useMatches();
invariant(parentRoute);
const data = parentRoute.data as UserPageLoaderData;
const parentRouteData = parentRoute.data as UserPageLoaderData;
const transition = useTransition();
return (
<Form className="u-edit__container" method="post">
<div>
<label htmlFor="country">Country</label>
<label htmlFor="country">{t("user:country")}</label>
<select
className="u-edit__country-select"
name="country"
id="country"
defaultValue={data.country?.code ?? ""}
defaultValue={parentRouteData.country?.code ?? ""}
data-cy="country-select"
>
<option value="" />
{Object.entries(countries).map(([code, country]) => (
<option key={code} value={code}>
{data.countries.map((country) => (
<option key={country.code} value={country.code}>
{`${country.name} ${country.emoji}`}
</option>
))}
</select>
</div>
<BioTextarea initialValue={data.bio} />
<BioTextarea initialValue={parentRouteData.bio} />
<Button
loadingText="Saving..."
loadingText={t("common:actions.saving")}
type="submit"
loading={transition.state === "submitting"}
data-cy="submit-button"
>
Save
{t("common:actions.save")}
</Button>
</Form>
);
}
function BioTextarea({ initialValue }: { initialValue: User["bio"] }) {
const { t } = useTranslation("user");
const [value, setValue] = React.useState(initialValue ?? "");
return (
@@ -94,7 +127,7 @@ function BioTextarea({ initialValue }: { initialValue: User["bio"] }) {
htmlFor="bio"
valueLimits={{ current: value.length, max: USER_BIO_MAX_LENGTH }}
>
Bio
{t("bio")}
</Label>
<textarea
id="bio"

View File

@@ -13,11 +13,16 @@ import { TwitterIcon } from "~/components/icons/Twitter";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { badgeExplanationText } from "../badges/$id";
import { Badge } from "~/components/Badge";
import { useTranslation } from "react-i18next";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle = {
i18n: "badges",
};
export default function UserInfoPage() {
const [, parentRoute] = useMatches();
invariant(parentRoute);
@@ -113,6 +118,7 @@ function SocialLinkIcon({ type }: Pick<SocialLinkProps, "type">) {
}
function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
const { t } = useTranslation("badges");
const [badges, setBadges] = React.useState(props.badges);
// keep badges in sync when route changes from one user profile to another
@@ -165,7 +171,7 @@ function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
) : null}
</div>
<div className="u__badge-explanation" data-cy="badge-explanation">
{badgeExplanationText(bigBadge)}
{badgeExplanationText(t, bigBadge)}
</div>
</div>
);

12
app/utils/i18n.server.ts Normal file
View File

@@ -0,0 +1,12 @@
// .server because these are dangerous to use on client due to hydration mismatches
// (Node.js and browser can display different language depending on implementation)
export function translatedCountry({
language,
countryCode,
}: {
language: string;
countryCode: string;
}) {
return new Intl.DisplayNames([language], { type: "region" }).of(countryCode);
}

View File

@@ -0,0 +1,6 @@
{
"patreon": "Supporter of sendou.ink on Patreon",
"patreon+": "Supporter+ of sendou.ink on Patreon",
"tournament_one": "Awarded for winning {{tournament}}",
"tournament_other": "Awarded for winning {{tournament}} (×{{count}})"
}

View File

@@ -13,5 +13,9 @@
"footer.twitter.subtitle": "Updates",
"footer.discord.subtitle": "Help & feedback",
"footer.patreon.subtitle": "Support",
"footer.thanks": "Thanks to the patrons for the support"
"footer.thanks": "Thanks to the patrons for the support",
"actions.save": "Save",
"actions.saving": "Saving...",
"actions.edit": "Edit"
}

View File

@@ -0,0 +1,4 @@
{
"country": "Country",
"bio": "Bio"
}

View File

@@ -3,6 +3,8 @@ import "react-i18next";
import type common from "../public/locales/en/common.json";
import type faq from "../public/locales/en/faq.json";
import type contributions from "../public/locales/en/contributions.json";
import type user from "../public/locales/en/user.json";
import type badges from "../public/locales/en/badges.json";
declare module "react-i18next" {
interface CustomTypeOptions {
@@ -11,6 +13,8 @@ declare module "react-i18next" {
common: typeof common;
faq: typeof faq;
contributions: typeof contributions;
user: typeof user;
badges: typeof badges;
};
}
}