From db4f633a476b14d98acad1b7502b2e8b7a4b4ba1 Mon Sep 17 00:00:00 2001 From: Remmy Cat Stock <3317423+remmycat@users.noreply.github.com> Date: Thu, 20 Oct 2022 22:25:00 +0200 Subject: [PATCH 1/2] Add type for route handles --- app/root.tsx | 3 ++- app/routes/analyzer.tsx | 3 ++- app/routes/badges.tsx | 3 ++- app/routes/builds.tsx | 3 ++- app/routes/builds/index.tsx | 3 ++- app/routes/calendar/$id/index.tsx | 4 ++-- app/routes/calendar/$id/report-winners.tsx | 3 ++- app/routes/calendar/index.tsx | 3 ++- app/routes/calendar/new.tsx | 3 ++- app/routes/contributions.tsx | 3 ++- app/routes/faq.tsx | 3 ++- app/routes/index.tsx | 3 ++- app/routes/maps.tsx | 3 ++- app/routes/object-damage.tsx | 3 ++- app/routes/u.$identifier.tsx | 4 ++-- app/routes/u.$identifier/builds/index.tsx | 8 ++++++-- app/routes/u.$identifier/builds/new.tsx | 4 ++-- app/routes/u.$identifier/index.tsx | 3 ++- app/utils/remix.ts | 14 ++++++++++++++ 19 files changed, 54 insertions(+), 22 deletions(-) diff --git a/app/root.tsx b/app/root.tsx index cd7ba038d..b93d03d18 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -32,6 +32,7 @@ import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme"; import { getThemeSession } from "./modules/theme/session.server"; import { COMMON_PREVIEW_IMAGE } from "./utils/urls"; import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration"; +import { type SendouRouteHandle } from "~/utils/remix"; export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => { // reload on language change so the selected language gets set into the cookie @@ -94,7 +95,7 @@ export const loader: LoaderFunction = async ({ request }) => { ); }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "common", }; diff --git a/app/routes/analyzer.tsx b/app/routes/analyzer.tsx index d66e243e9..8253e30c1 100644 --- a/app/routes/analyzer.tsx +++ b/app/routes/analyzer.tsx @@ -34,6 +34,7 @@ import { type SubWeaponId, } from "~/modules/in-game-lists"; import styles from "~/styles/analyzer.css"; +import { type SendouRouteHandle } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { specialWeaponImageUrl, subWeaponImageUrl } from "~/utils/urls"; @@ -49,7 +50,7 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons", "analyzer"], }; diff --git a/app/routes/badges.tsx b/app/routes/badges.tsx index 42bd6307c..0106ce443 100644 --- a/app/routes/badges.tsx +++ b/app/routes/badges.tsx @@ -9,6 +9,7 @@ import styles from "~/styles/badges.css"; import { BORZOIC_TWITTER, FAQ_PAGE } from "~/utils/urls"; import { Trans, useTranslation } from "react-i18next"; import { useAnimateListEntry } from "~/hooks/useAnimateListEntry"; +import { type SendouRouteHandle } from "~/utils/remix"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -18,7 +19,7 @@ export interface BadgesLoaderData { badges: FindAll; } -export const handle = { +export const handle: SendouRouteHandle = { i18n: "badges", }; diff --git a/app/routes/builds.tsx b/app/routes/builds.tsx index e3800cf64..3841500d5 100644 --- a/app/routes/builds.tsx +++ b/app/routes/builds.tsx @@ -6,6 +6,7 @@ import { LinkButton } from "~/components/Button"; import { Main } from "~/components/Main"; import { useUser } from "~/modules/auth"; import type { MainWeaponId } from "~/modules/in-game-lists"; +import { type SendouRouteHandle } from "~/utils/remix"; import styles from "~/styles/builds.css"; import { atOrError } from "~/utils/arrays"; @@ -15,7 +16,7 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons", "builds"], }; diff --git a/app/routes/builds/index.tsx b/app/routes/builds/index.tsx index 63b0b7080..87553e8e4 100644 --- a/app/routes/builds/index.tsx +++ b/app/routes/builds/index.tsx @@ -4,8 +4,9 @@ import { Image } from "~/components/Image"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { weaponCategories, weaponIdIsNotAlt } from "~/modules/in-game-lists"; import { mainWeaponImageUrl, mySlugify, weaponCategoryUrl } from "~/utils/urls"; +import { type SendouRouteHandle } from "~/utils/remix"; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "weapons", }; diff --git a/app/routes/calendar/$id/index.tsx b/app/routes/calendar/$id/index.tsx index 2ba78c7fb..714e686c8 100644 --- a/app/routes/calendar/$id/index.tsx +++ b/app/routes/calendar/$id/index.tsx @@ -28,7 +28,7 @@ import { import calendarStyles from "~/styles/calendar-event.css"; import mapsStyles from "~/styles/maps.css"; import { databaseTimestampToDate } from "~/utils/dates"; -import { notFoundIfFalsy } from "~/utils/remix"; +import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix"; import { discordFullName, makeTitle } from "~/utils/strings"; import { calendarEditPage, @@ -60,7 +60,7 @@ export const meta: MetaFunction = (args) => { }; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["calendar", "game-misc"], }; diff --git a/app/routes/calendar/$id/report-winners.tsx b/app/routes/calendar/$id/report-winners.tsx index a3c501115..588ec5fc7 100644 --- a/app/routes/calendar/$id/report-winners.tsx +++ b/app/routes/calendar/$id/report-winners.tsx @@ -17,6 +17,7 @@ import { notFoundIfFalsy, safeParseRequestFormData, validate, + type SendouRouteHandle, } from "~/utils/remix"; import { actualNumber, id, safeJSONParse, toArray } from "~/utils/zod"; import * as React from "react"; @@ -135,7 +136,7 @@ export const action: ActionFunction = async ({ request, params }) => { return redirect(calendarEventPage(parsedParams.id)); }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "calendar", }; diff --git a/app/routes/calendar/index.tsx b/app/routes/calendar/index.tsx index 735bd364e..ec8983c2f 100644 --- a/app/routes/calendar/index.tsx +++ b/app/routes/calendar/index.tsx @@ -25,6 +25,7 @@ import type { Unpacked } from "~/utils/types"; import { calendarReportWinnersPage, resolveBaseUrl } from "~/utils/urls"; import { actualNumber } from "~/utils/zod"; import { Tags } from "./components/Tags"; +import { type SendouRouteHandle } from "~/utils/remix"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -45,7 +46,7 @@ export const meta: MetaFunction = (args) => { }; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "calendar", }; diff --git a/app/routes/calendar/new.tsx b/app/routes/calendar/new.tsx index dbc2e7f21..af102411e 100644 --- a/app/routes/calendar/new.tsx +++ b/app/routes/calendar/new.tsx @@ -44,6 +44,7 @@ import { badRequestIfFalsy, parseRequestFormData, validate, + type SendouRouteHandle, } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { calendarEventPage } from "~/utils/urls"; @@ -181,7 +182,7 @@ export const action: ActionFunction = async ({ request }) => { } }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "calendar", }; diff --git a/app/routes/contributions.tsx b/app/routes/contributions.tsx index e53836dd1..d6e8228d3 100644 --- a/app/routes/contributions.tsx +++ b/app/routes/contributions.tsx @@ -11,6 +11,7 @@ import { SENDOU_TWITTER_URL, UBERU_TWITTER, } from "~/utils/urls"; +import { type SendouRouteHandle } from "~/utils/remix"; export const meta: MetaFunction = () => { return { @@ -18,7 +19,7 @@ export const meta: MetaFunction = () => { }; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "contributions", }; diff --git a/app/routes/faq.tsx b/app/routes/faq.tsx index dd1e6089b..1f93a218e 100644 --- a/app/routes/faq.tsx +++ b/app/routes/faq.tsx @@ -4,6 +4,7 @@ import { Main } from "~/components/Main"; import { useSetTitle } from "~/hooks/useSetTitle"; import styles from "~/styles/faq.css"; import { makeTitle } from "~/utils/strings"; +import { type SendouRouteHandle } from "~/utils/remix"; const AMOUNT_OF_QUESTIONS = 3; @@ -18,7 +19,7 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "faq", }; diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 02d3a7cd9..334a69d73 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -26,6 +26,7 @@ import { userPage, } from "~/utils/urls"; import { Tags } from "./calendar/components/Tags"; +import { type SendouRouteHandle } from "~/utils/remix"; const RECENT_ARTICLES_TO_SHOW = 3; @@ -33,7 +34,7 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons", "builds"], }; diff --git a/app/routes/maps.tsx b/app/routes/maps.tsx index 6d4589039..3b7d8b661 100644 --- a/app/routes/maps.tsx +++ b/app/routes/maps.tsx @@ -44,6 +44,7 @@ import { modeImageUrl, stageImageUrl, } from "~/utils/urls"; +import { type SendouRouteHandle } from "~/utils/remix"; const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2; @@ -63,7 +64,7 @@ export const meta: MetaFunction = (args) => { }; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "game-misc", }; diff --git a/app/routes/object-damage.tsx b/app/routes/object-damage.tsx index 4cdfe3aea..c26bfedfd 100644 --- a/app/routes/object-damage.tsx +++ b/app/routes/object-damage.tsx @@ -2,8 +2,9 @@ import { WeaponCombobox } from "~/components/Combobox"; import { Main } from "~/components/Main"; import { useObjectDamage } from "~/modules/analyzer"; import type { MainWeaponId } from "~/modules/in-game-lists"; +import { type SendouRouteHandle } from "~/utils/remix"; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons"], }; diff --git a/app/routes/u.$identifier.tsx b/app/routes/u.$identifier.tsx index c19c9ef27..f5395163a 100644 --- a/app/routes/u.$identifier.tsx +++ b/app/routes/u.$identifier.tsx @@ -15,7 +15,7 @@ import { db } from "~/db"; import { useUser } from "~/modules/auth"; import { i18next } from "~/modules/i18n"; import { translatedCountry } from "~/utils/i18n.server"; -import { notFoundIfFalsy } from "~/utils/remix"; +import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix"; import { discordFullName, makeTitle } from "~/utils/strings"; import styles from "~/styles/u.css"; import invariant from "tiny-invariant"; @@ -39,7 +39,7 @@ export const meta: MetaFunction = ({ data }: { data: UserPageLoaderData }) => { }; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "user", }; diff --git a/app/routes/u.$identifier/builds/index.tsx b/app/routes/u.$identifier/builds/index.tsx index 4a0937312..94d7a356b 100644 --- a/app/routes/u.$identifier/builds/index.tsx +++ b/app/routes/u.$identifier/builds/index.tsx @@ -10,7 +10,11 @@ import { BUILD } from "~/constants"; import { db } from "~/db"; import { getUser, requireUser, useUser } from "~/modules/auth"; import { atOrError } from "~/utils/arrays"; -import { notFoundIfFalsy, parseRequestFormData } from "~/utils/remix"; +import { + notFoundIfFalsy, + parseRequestFormData, + type SendouRouteHandle, +} from "~/utils/remix"; import { userNewBuildPage } from "~/utils/urls"; import { actualNumber, id } from "~/utils/zod"; import { type UserPageLoaderData, userParamsSchema } from "../../u.$identifier"; @@ -39,7 +43,7 @@ export const action: ActionFunction = async ({ request }) => { return null; }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons", "builds"], }; diff --git a/app/routes/u.$identifier/builds/new.tsx b/app/routes/u.$identifier/builds/new.tsx index b46e2e5fa..44cf417d7 100644 --- a/app/routes/u.$identifier/builds/new.tsx +++ b/app/routes/u.$identifier/builds/new.tsx @@ -30,7 +30,7 @@ import type { BuildAbilitiesTupleWithUnknown, MainWeaponId, } from "~/modules/in-game-lists/types"; -import { parseRequestFormData } from "~/utils/remix"; +import { parseRequestFormData, type SendouRouteHandle } from "~/utils/remix"; import { modeImageUrl, userBuildsPage } from "~/utils/urls"; import { actualNumber, @@ -154,7 +154,7 @@ export const action: ActionFunction = async ({ request }) => { return redirect(userBuildsPage(user)); }; -export const handle = { +export const handle: SendouRouteHandle = { i18n: ["weapons", "builds", "gear"], }; diff --git a/app/routes/u.$identifier/index.tsx b/app/routes/u.$identifier/index.tsx index 00a488d29..6d5e4f0bb 100644 --- a/app/routes/u.$identifier/index.tsx +++ b/app/routes/u.$identifier/index.tsx @@ -14,8 +14,9 @@ import type { Unpacked } from "~/utils/types"; import { assertUnreachable } from "~/utils/types"; import { badgeExplanationText } from "../badges/$id"; import type { UserPageLoaderData } from "../u.$identifier"; +import { type SendouRouteHandle } from "~/utils/remix"; -export const handle = { +export const handle: SendouRouteHandle = { i18n: "badges", }; diff --git a/app/utils/remix.ts b/app/utils/remix.ts index 2f381cef4..6ebce50d6 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { type ReactNode } from "react"; +import { type Namespace } from "react-i18next"; export function notFoundIfFalsy(value: T | null | undefined): T { if (!value) throw new Response(null, { status: 404 }); @@ -89,3 +91,15 @@ export function validate(condition: any, status = 400): asserts condition { throw new Response(null, { status }); } + +/** + * Our custom type for route handles - the keys are defined by us or + * libraries that parse them. + * + * Can be set per route using `export const handle: SendouRouteHandle = { };` + * Can be accessed for all currently active routes via the `useMatches()` hook. + */ +export type SendouRouteHandle = { + /** The i18n translation files used for this route, via remix-i18next */ + i18n?: Namespace; +}; From 85fda204c0efc162bb38d9c7d69109ecf662fdb2 Mon Sep 17 00:00:00 2001 From: Remmy Cat Stock <3317423+remmycat@users.noreply.github.com> Date: Fri, 21 Oct 2022 00:48:01 +0200 Subject: [PATCH 2/2] Refactor breadcrumbs into a component that uses route handles --- app/components/Breadcrumbs.tsx | 58 ++++++++++++++++++++++++++++++++++ app/routes/builds.tsx | 44 +++----------------------- app/routes/builds/$slug.tsx | 14 +++++++- app/styles/builds.css | 7 ---- app/styles/common.css | 7 ++++ app/utils/arrays.ts | 5 +++ app/utils/remix.ts | 12 +++++-- 7 files changed, 98 insertions(+), 49 deletions(-) create mode 100644 app/components/Breadcrumbs.tsx diff --git a/app/components/Breadcrumbs.tsx b/app/components/Breadcrumbs.tsx new file mode 100644 index 000000000..c17d627be --- /dev/null +++ b/app/components/Breadcrumbs.tsx @@ -0,0 +1,58 @@ +import { Link, useMatches } from "@remix-run/react"; +import { useMemo, Fragment } from "react"; +import { useTranslation } from "react-i18next"; +import { isDefined } from "~/utils/arrays"; +import { type SendouRouteHandle } from "~/utils/remix"; + +type Crumb = { + path: string; + name: string; +}; + +function useBreadcrumbs(): Crumb[] { + const matches = useMatches(); + const { t } = useTranslation("common"); + + return useMemo( + () => + matches + .map((match) => { + const handle = match.handle as undefined | SendouRouteHandle; + const name = handle?.breadcrumb?.({ match, t }); + return name ? { path: match.pathname, name } : undefined; + }) + .filter(isDefined), + [matches, t] + ); +} + +export function Breadcrumbs() { + const breadcrumbs = useBreadcrumbs(); + + const showBreadcrumbs = breadcrumbs.length > 0; + + if (!showBreadcrumbs) { + return null; + } + + return ( + + ); +} diff --git a/app/routes/builds.tsx b/app/routes/builds.tsx index 3841500d5..6eddd29c6 100644 --- a/app/routes/builds.tsx +++ b/app/routes/builds.tsx @@ -1,16 +1,13 @@ import { type LinksFunction } from "@remix-run/node"; -import { Link, Outlet, useMatches, useParams } from "@remix-run/react"; -import type * as React from "react"; +import { Outlet } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { LinkButton } from "~/components/Button"; import { Main } from "~/components/Main"; import { useUser } from "~/modules/auth"; -import type { MainWeaponId } from "~/modules/in-game-lists"; import { type SendouRouteHandle } from "~/utils/remix"; - import styles from "~/styles/builds.css"; -import { atOrError } from "~/utils/arrays"; -import { BUILDS_PAGE, userNewBuildPage } from "~/utils/urls"; +import { userNewBuildPage } from "~/utils/urls"; +import { Breadcrumbs } from "~/components/Breadcrumbs"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -18,34 +15,17 @@ export const links: LinksFunction = () => { export const handle: SendouRouteHandle = { i18n: ["weapons", "builds"], + breadcrumb: ({ t }) => t("pages.builds"), }; export default function BuildsLayoutPage() { const user = useUser(); - const matches = useMatches(); const { t } = useTranslation(["weapons", "common", "builds"]); - const params = useParams(); - - const weaponId: MainWeaponId | undefined = atOrError(matches, -1).data?.[ - "weaponId" - ]; return (
- + {user && ( {t("builds:addBuild")} @@ -56,17 +36,3 @@ export default function BuildsLayoutPage() {
); } - -function SometimesLink({ - children, - isLink, -}: { - children: React.ReactNode; - isLink: boolean; -}) { - if (isLink) { - return {children}; - } - - return
{children}
; -} diff --git a/app/routes/builds/$slug.tsx b/app/routes/builds/$slug.tsx index 3fe2fc6bf..b0f716385 100644 --- a/app/routes/builds/$slug.tsx +++ b/app/routes/builds/$slug.tsx @@ -11,6 +11,7 @@ import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS } from "~/constants"; import { db } from "~/db"; import { i18next } from "~/modules/i18n"; import { weaponIdIsNotAlt } from "~/modules/in-game-lists"; +import { type SendouRouteHandle } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; @@ -40,9 +41,12 @@ export const loader = async ({ request, params }: LoaderArgs) => { BUILDS_PAGE_MAX_BUILDS ); + const weaponName = t(`weapons:MAIN_${weaponId}`); + return { weaponId, - title: makeTitle([t(`weapons:MAIN_${weaponId}`), t("common:pages.builds")]), + weaponName, + title: makeTitle([weaponName, t("common:pages.builds")]), builds: db.builds.buildsByWeaponId({ weaponId, limit, @@ -51,6 +55,14 @@ export const loader = async ({ request, params }: LoaderArgs) => { }; }; +export const handle: SendouRouteHandle = { + breadcrumb: ({ match }) => { + const data = match.data as SerializeFrom | null; + + return data ? data.weaponName : "Unknown"; + }, +}; + export default function WeaponsBuildsPage() { const data = useLoaderData(); const { t } = useTranslation(["common"]); diff --git a/app/styles/builds.css b/app/styles/builds.css index 34d8fa9f9..3960f1f83 100644 --- a/app/styles/builds.css +++ b/app/styles/builds.css @@ -4,13 +4,6 @@ justify-content: space-between; } -.builds__breadcrumbs { - display: flex; - font-size: var(--fonts-xs); - font-weight: var(--bold); - gap: var(--s-1); -} - .builds__category { display: flex; flex-direction: column; diff --git a/app/styles/common.css b/app/styles/common.css index ab61e10dc..2aa9a71f0 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -872,3 +872,10 @@ dialog::backdrop { .ability-selector__ability-button.is-dragging { box-shadow: 0 0 100px inset rgb(255 255 255 / 25%); } + +.breadcrumbs { + display: flex; + font-size: var(--fonts-xs); + font-weight: var(--bold); + gap: var(--s-1); +} diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index 3c29f98a7..2ab5b0efd 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -40,3 +40,8 @@ export function normalizeFormFieldArray( ): string[] { return value == null ? [] : typeof value === "string" ? [value] : value; } + +/** Can be used as a strongly typed array filter */ +export function isDefined(value: T | undefined | null): value is T { + return value !== null && value !== undefined; +} diff --git a/app/utils/remix.ts b/app/utils/remix.ts index 6ebce50d6..be1be09f9 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { type ReactNode } from "react"; -import { type Namespace } from "react-i18next"; +import { type TFunction, type Namespace } from "react-i18next"; +import { type RouteMatch } from "@remix-run/react"; export function notFoundIfFalsy(value: T | null | undefined): T { if (!value) throw new Response(null, { status: 404 }); @@ -102,4 +102,12 @@ export function validate(condition: any, status = 400): asserts condition { export type SendouRouteHandle = { /** The i18n translation files used for this route, via remix-i18next */ i18n?: Namespace; + /** + * A function that returns the breadcrumb text that should be displayed in + * the component + */ + breadcrumb?: (args: { + match: RouteMatch; + t: TFunction<"common", undefined>; + }) => string | undefined; };