mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-12 05:35:16 -05:00
Merge pull request #1029 from Sendouc/breadcrumbs
Generic Breadcrumbs component/mechanism
This commit is contained in:
commit
b2149f64f5
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user