mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 12:46:10 -05:00
Translate user page
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
12
app/utils/i18n.server.ts
Normal 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);
|
||||
}
|
||||
6
public/locales/en/badges.json
Normal file
6
public/locales/en/badges.json
Normal 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}})"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
4
public/locales/en/user.json
Normal file
4
public/locales/en/user.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"country": "Country",
|
||||
"bio": "Bio"
|
||||
}
|
||||
4
types/react-i18next.d.ts
vendored
4
types/react-i18next.d.ts
vendored
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user