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/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..6eddd29c6 100644
--- a/app/routes/builds.tsx
+++ b/app/routes/builds.tsx
@@ -1,50 +1,31 @@
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 }];
};
-export const handle = {
+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")}
@@ -55,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/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/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 2f381cef4..be1be09f9 100644
--- a/app/utils/remix.ts
+++ b/app/utils/remix.ts
@@ -1,4 +1,6 @@
import { z } from "zod";
+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 });
@@ -89,3 +91,23 @@ 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;
+ /**
+ * A function that returns the breadcrumb text that should be displayed in
+ * the component
+ */
+ breadcrumb?: (args: {
+ match: RouteMatch;
+ t: TFunction<"common", undefined>;
+ }) => string | undefined;
+};