mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Refactor breadcrumbs into a component that uses route handles
This commit is contained in:
58
app/components/Breadcrumbs.tsx
Normal file
58
app/components/Breadcrumbs.tsx
Normal file
@@ -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 (
|
||||
<nav className="breadcrumbs">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
return <div key={crumb.path}>{crumb.name}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={crumb.path}>
|
||||
<div>
|
||||
<Link to={crumb.path}>{crumb.name}</Link>
|
||||
</div>
|
||||
<div>/</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Main className="stack lg">
|
||||
<div className="builds__top-container">
|
||||
<nav className="builds__breadcrumbs">
|
||||
<SometimesLink isLink={Boolean(params["slug"])}>
|
||||
{t("common:pages.builds")}
|
||||
</SometimesLink>
|
||||
{typeof weaponId === "number" && (
|
||||
<>
|
||||
<div>/</div>
|
||||
<SometimesLink isLink={false}>
|
||||
{t(`weapons:MAIN_${weaponId}`)}
|
||||
</SometimesLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<Breadcrumbs />
|
||||
{user && (
|
||||
<LinkButton to={userNewBuildPage(user)} tiny>
|
||||
{t("builds:addBuild")}
|
||||
@@ -56,17 +36,3 @@ export default function BuildsLayoutPage() {
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function SometimesLink({
|
||||
children,
|
||||
isLink,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isLink: boolean;
|
||||
}) {
|
||||
if (isLink) {
|
||||
return <Link to={BUILDS_PAGE}>{children}</Link>;
|
||||
}
|
||||
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -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<typeof loader> | null;
|
||||
|
||||
return data ? data.weaponName : "Unknown";
|
||||
},
|
||||
};
|
||||
|
||||
export default function WeaponsBuildsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<T>(value: T | undefined | null): value is T {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
@@ -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<T>(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 <Breadcrumb> component
|
||||
*/
|
||||
breadcrumb?: (args: {
|
||||
match: RouteMatch;
|
||||
t: TFunction<"common", undefined>;
|
||||
}) => string | undefined;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user