sendou.ink/app/features/user-page/routes/u.$identifier.builds.tsx
Kalle 77978c450f
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
New user page (#2812)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-02-16 19:26:57 +02:00

377 lines
10 KiB
TypeScript

import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData, useMatches } from "react-router";
import { AddNewButton } from "~/components/AddNewButton";
import { BuildCard } from "~/components/BuildCard";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormMessage } from "~/components/FormMessage";
import { Image, WeaponImage } from "~/components/Image";
import { LockIcon } from "~/components/icons/Lock";
import { SortIcon } from "~/components/icons/Sort";
import { TrashIcon } from "~/components/icons/Trash";
import { UnlockIcon } from "~/components/icons/Unlock";
import { SubmitButton } from "~/components/SubmitButton";
import { BUILD_SORT_IDENTIFIERS, type BuildSort } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userNewBuildPage, userPage, weaponCategoryUrl } from "~/utils/urls";
import { action } from "../actions/u.$identifier.builds.server";
import { SubPageHeader } from "../components/SubPageHeader";
import {
loader,
type UserBuildsPageData,
} from "../loaders/u.$identifier.builds.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import { DEFAULT_BUILD_SORT } from "../user-page-constants";
export { loader, action };
import styles from "./u.$identifier.builds.module.css";
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
};
type BuildFilter = "ALL" | "PUBLIC" | "PRIVATE" | MainWeaponId;
export default function UserBuildsPage() {
const { t } = useTranslation(["builds", "user"]);
const user = useUser();
const layoutData = useMatches().at(-2)!.data as UserPageLoaderData;
const data = useLoaderData<typeof loader>();
const [weaponFilter, setWeaponFilter] = useSearchParamState<BuildFilter>({
defaultValue: "ALL",
name: "weapon",
revive: (value) =>
["ALL", "PUBLIC", "PRIVATE"].includes(value)
? (value as BuildFilter)
: mainWeaponIds.find((id) => id === Number(value)),
});
const isOwnPage = user?.id === layoutData.user.id;
const [changingSorting, setChangingSorting] = useSearchParamState({
defaultValue: false,
name: "sorting",
revive: (value) => value === "true" && isOwnPage,
});
const closeSortingDialog = React.useCallback(
() => setChangingSorting(false),
[setChangingSorting],
);
const builds =
weaponFilter === "ALL"
? data.builds
: weaponFilter === "PUBLIC"
? data.builds.filter((build) => !build.private)
: weaponFilter === "PRIVATE"
? data.builds.filter((build) => build.private)
: data.builds.filter((build) =>
build.weapons
.map((wpn) => wpn.weaponSplId)
.includes(weaponFilter),
);
return (
<div className="stack lg">
{changingSorting ? (
<ChangeSortingDialog close={closeSortingDialog} />
) : null}
<SubPageHeader user={layoutData.user} backTo={userPage(layoutData.user)}>
{isOwnPage ? (
<>
<SendouButton
onPress={() => setChangingSorting(true)}
size="small"
variant="outlined"
icon={<SortIcon />}
data-testid="change-sorting-button"
>
{t("user:builds.sorting.changeButton")}
</SendouButton>
<AddNewButton navIcon="builds" to={userNewBuildPage(user)} />
</>
) : null}
</SubPageHeader>
<BuildsFilters
weaponFilter={weaponFilter}
setWeaponFilter={setWeaponFilter}
/>
{builds.length > 0 ? (
<div className={styles.buildsContainer}>
{builds.map((build) => (
<BuildCard key={build.id} build={build} canEdit={isOwnPage} />
))}
</div>
) : (
<div className="text-center text-lg text-lighter font-semi-bold">
{t("noBuilds")}
</div>
)}
</div>
);
}
function BuildsFilters({
weaponFilter,
setWeaponFilter,
}: {
weaponFilter: BuildFilter;
setWeaponFilter: (weaponFilter: BuildFilter) => void;
}) {
const { t } = useTranslation(["weapons", "builds"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const layoutData = useMatches().at(-2)!.data as UserPageLoaderData;
if (data.builds.length === 0) return null;
const privateBuildsCount = data.builds.filter(
(build) => build.private,
).length;
const publicBuildsCount = data.builds.length - privateBuildsCount;
const showPublicPrivateFilters =
user?.id === layoutData.user.id && privateBuildsCount > 0;
return (
<div className="stack horizontal sm flex-wrap">
<SendouButton
onPress={() => setWeaponFilter("ALL")}
variant={weaponFilter === "ALL" ? undefined : "outlined"}
size="small"
className="u__build-filter-button"
>
{t("builds:stats.all")} ({data.builds.length})
</SendouButton>
{showPublicPrivateFilters ? (
<>
<SendouButton
onPress={() => setWeaponFilter("PUBLIC")}
variant={weaponFilter === "PUBLIC" ? undefined : "outlined"}
size="small"
className="u__build-filter-button"
icon={<UnlockIcon />}
>
{t("builds:stats.public")} ({publicBuildsCount})
</SendouButton>
<SendouButton
onPress={() => setWeaponFilter("PRIVATE")}
variant={weaponFilter === "PRIVATE" ? undefined : "outlined"}
size="small"
className="u__build-filter-button"
icon={<LockIcon />}
>
{t("builds:stats.private")} ({privateBuildsCount})
</SendouButton>
</>
) : null}
<WeaponFilterMenu
mainWeaponIds={mainWeaponIds}
counts={data.weaponCounts}
weaponFilter={weaponFilter}
setWeaponFilter={setWeaponFilter}
/>
</div>
);
}
const MISSING_SORT_VALUE = "null";
function ChangeSortingDialog({ close }: { close: () => void }) {
const data = useLoaderData<typeof loader>();
const [buildSorting, setBuildSorting] = React.useState<
ReadonlyArray<BuildSort | null>
>(() => {
if (!data.buildSorting) return [...DEFAULT_BUILD_SORT, null];
if (data.buildSorting.length === BUILD_SORT_IDENTIFIERS.length)
return data.buildSorting;
return [...data.buildSorting, null];
});
const { t } = useTranslation(["common", "user"]);
const fetcher = useFetcher();
React.useEffect(() => {
if (fetcher.state !== "loading") return;
close();
}, [fetcher.state, close]);
const canAddMoreSorting = buildSorting.length < BUILD_SORT_IDENTIFIERS.length;
const changeSorting = (idx: number, newIdentifier: BuildSort | null) => {
const newSorting = buildSorting.map((oldIdentifier, i) =>
i === idx ? newIdentifier : oldIdentifier,
);
if (canAddMoreSorting && newSorting[newSorting.length - 1] !== null) {
newSorting.push(null);
}
setBuildSorting(newSorting);
};
const deleteLastSorting = () => {
setBuildSorting((prev) => [...prev.filter(Boolean).slice(0, -1), null]);
};
return (
<SendouDialog heading={t("user:builds.sorting.header")} onClose={close}>
<fetcher.Form method="post">
<input
type="hidden"
name="buildSorting"
value={JSON.stringify(buildSorting.filter(Boolean))}
/>
<div className="stack lg">
<div className="stack md">
<FormMessage type="info">
{t("user:builds.sorting.info")}
</FormMessage>
<SendouButton
className="ml-auto"
variant="minimal"
size="small"
onPress={() => setBuildSorting([...DEFAULT_BUILD_SORT, null])}
>
{t("user:builds.sorting.backToDefaults")}
</SendouButton>
{buildSorting.map((sort, i) => {
const isLast = i === buildSorting.length - 1;
const isSecondToLast = i === buildSorting.length - 2;
if (isLast && canAddMoreSorting) {
return (
<ChangeSortingDialogSelect
key={i}
identifiers={BUILD_SORT_IDENTIFIERS.filter(
(identifier) =>
!buildSorting.slice(0, -1).includes(identifier),
)}
value={sort}
changeValue={(newValue) => changeSorting(i, newValue)}
/>
);
}
return (
<div key={i} className="stack horizontal justify-between">
<div className="font-bold">
{i + 1}) {t(`user:builds.sorting.${sort!}`)}
</div>
{(isLast && !canAddMoreSorting) ||
(canAddMoreSorting && isSecondToLast) ? (
<SendouButton
icon={<TrashIcon />}
variant="minimal-destructive"
onPress={deleteLastSorting}
/>
) : null}
</div>
);
})}
</div>
<div>
<SubmitButton _action="UPDATE_SORTING">
{t("common:actions.save")}
</SubmitButton>
</div>
</div>
</fetcher.Form>
</SendouDialog>
);
}
function ChangeSortingDialogSelect({
identifiers,
value,
changeValue,
}: {
identifiers: BuildSort[];
value: BuildSort | null;
changeValue: (value: BuildSort | null) => void;
}) {
const { t } = useTranslation(["user"]);
return (
<select
value={value ?? MISSING_SORT_VALUE}
onChange={(e) => {
if (e.target.value === MISSING_SORT_VALUE) changeValue(null);
changeValue(e.target.value as BuildSort);
}}
>
<option value={MISSING_SORT_VALUE}>-</option>
{identifiers.map((identifier) => {
return (
<option key={identifier} value={identifier}>
{t(`user:builds.sorting.${identifier}`)}
</option>
);
})}
</select>
);
}
function WeaponFilterMenu({
mainWeaponIds,
counts,
weaponFilter,
setWeaponFilter,
}: {
mainWeaponIds: MainWeaponId[];
counts: UserBuildsPageData["weaponCounts"];
weaponFilter: BuildFilter;
setWeaponFilter: (weaponFilter: MainWeaponId) => void;
}) {
const { t } = useTranslation(["weapons", "builds"]);
return (
<SendouMenu
scrolling
trigger={
<SendouButton
variant={typeof weaponFilter === "number" ? undefined : "outlined"}
size="small"
className="u__build-filter-button"
>
<Image
path={weaponCategoryUrl("SHOOTERS")}
width={24}
height={24}
alt=""
/>
{t("builds:filters.filterByWeapon")}
</SendouButton>
}
>
{mainWeaponIds.map((weaponId) => {
const count = counts[weaponId];
if (!count) return null;
return (
<SendouMenuItem
key={weaponId}
icon={
<WeaponImage weaponSplId={weaponId} variant="build" size={18} />
}
onAction={() => setWeaponFilter(weaponId)}
isActive={weaponFilter === weaponId}
>
{`${t(`weapons:MAIN_${weaponId}`)} (${count})`}
</SendouMenuItem>
);
})}
</SendouMenu>
);
}