Merge pull request #1029 from Sendouc/breadcrumbs

Generic Breadcrumbs component/mechanism
This commit is contained in:
Kalle 2022-10-21 22:03:21 +03:00 committed by GitHub
commit b2149f64f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 150 additions and 69 deletions

View 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>
);
}

View File

@ -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",
};

View File

@ -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"],
};

View File

@ -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",
};

View File

@ -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 (
<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")}
@ -55,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>;
}

View File

@ -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"]);

View File

@ -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",
};

View File

@ -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"],
};

View File

@ -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",
};

View File

@ -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",
};

View File

@ -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",
};

View File

@ -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",
};

View File

@ -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",
};

View File

@ -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"],
};

View File

@ -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",
};

View File

@ -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"],
};

View File

@ -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",
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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",
};

View File

@ -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;

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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<T>(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 <Breadcrumb> component
*/
breadcrumb?: (args: {
match: RouteMatch;
t: TFunction<"common", undefined>;
}) => string | undefined;
};