mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
381
apps/web-react/app/components/BuildCard.tsx
Normal file
381
apps/web-react/app/components/BuildCard.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
HardDriveDownload,
|
||||
Lock,
|
||||
MessageCircleMore,
|
||||
SquarePen,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { analyzerPage } from "~/features/build-analyzer/analyzer-urls";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
import {
|
||||
BuildGraphic,
|
||||
type BuildGraphicOwner,
|
||||
} from "~/features/img-export/components/BuildGraphic";
|
||||
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
BuildAbilitiesTuple,
|
||||
GearType,
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { UserWithPlusTier } from "~/utils/kysely.server";
|
||||
import { gearTypeToInitial } from "~/utils/strings";
|
||||
import {
|
||||
gearImageUrl,
|
||||
mainWeaponImageUrl,
|
||||
modeImageUrl,
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
userBuildsPage,
|
||||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { Ability } from "./Ability";
|
||||
import styles from "./BuildCard.module.css";
|
||||
import { LinkButton, SendouButton } from "./elements/Button";
|
||||
import { SendouPopover } from "./elements/Popover";
|
||||
import { SendouSwitch } from "./elements/Switch";
|
||||
import { FormWithConfirm } from "./FormWithConfirm";
|
||||
import { Image } from "./Image";
|
||||
import { LocaleTime } from "./LocaleTime";
|
||||
|
||||
interface BuildProps {
|
||||
build: Pick<
|
||||
Tables["Build"],
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "clothesGearSplId"
|
||||
| "headGearSplId"
|
||||
| "shoesGearSplId"
|
||||
| "updatedAt"
|
||||
| "isPrivate"
|
||||
> & {
|
||||
abilities: BuildAbilitiesTuple;
|
||||
modes: ModeShort[] | null;
|
||||
weapons: Array<BuildWeaponWithTop500Info>;
|
||||
};
|
||||
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier"> &
|
||||
Partial<
|
||||
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
|
||||
>;
|
||||
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
|
||||
showOwner?: boolean;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function BuildCard({
|
||||
build,
|
||||
owner,
|
||||
showOwner = true,
|
||||
canEdit = false,
|
||||
}: BuildProps) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
|
||||
|
||||
const {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
clothesGearSplId,
|
||||
headGearSplId,
|
||||
shoesGearSplId,
|
||||
updatedAt,
|
||||
modes,
|
||||
weapons,
|
||||
abilities,
|
||||
} = build;
|
||||
|
||||
const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some(
|
||||
(id) => typeof id !== "number",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.card, { [styles.private]: build.isPrivate })}
|
||||
data-testid="build-card"
|
||||
>
|
||||
<div>
|
||||
<div className={styles.topRow}>
|
||||
{modes && modes.length > 0 && (
|
||||
<div className={styles.modes}>
|
||||
{modes.map((mode) => (
|
||||
<Image
|
||||
key={mode}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
title={t(`game-misc:MODE_LONG_${mode}` as any)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={18}
|
||||
height={18}
|
||||
testId={`build-mode-${mode}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className={styles.title} data-testid="build-title">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.dateAuthorRow}>
|
||||
{owner && showOwner ? (
|
||||
<>
|
||||
<Link to={userBuildsPage(owner)} className={styles.ownerLink}>
|
||||
{owner.username}
|
||||
</Link>
|
||||
<div>•</div>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.plusTier && showOwner ? (
|
||||
<>
|
||||
<span>+{owner.plusTier}</span>
|
||||
<div>•</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="stack horizontal sm items-center">
|
||||
{build.isPrivate ? (
|
||||
<div className={styles.privateText}>
|
||||
<Lock size={16} /> {t("common:build.private")}
|
||||
</div>
|
||||
) : null}
|
||||
<LocaleTime
|
||||
date={updatedAt}
|
||||
options={{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.weapons}>
|
||||
{weapons.map((weapon) => (
|
||||
<RoundWeaponImage key={weapon.weaponSplId} weapon={weapon} />
|
||||
))}
|
||||
{weapons.length === 1 && (
|
||||
<div className={styles.weaponText}>
|
||||
{t(`weapons:MAIN_${weapons[0].weaponSplId}` as any)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.gearAbilities, {
|
||||
[styles.noGear]: isNoGear,
|
||||
})}
|
||||
>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="HEAD"
|
||||
abilities={abilities[0]}
|
||||
gearId={headGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="CLOTHES"
|
||||
abilities={abilities[1]}
|
||||
gearId={clothesGearSplId}
|
||||
/>
|
||||
<AbilitiesRowWithGear
|
||||
gearType="SHOES"
|
||||
abilities={abilities[2]}
|
||||
gearId={shoesGearSplId}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.bottomRow}>
|
||||
<LinkButton
|
||||
to={analyzerPage({
|
||||
weaponId: weapons[0].weaponSplId,
|
||||
abilities: abilities.flat(),
|
||||
})}
|
||||
shape="circle"
|
||||
variant="minimal"
|
||||
size="small"
|
||||
>
|
||||
<Image
|
||||
size={24}
|
||||
alt={t("common:pages.analyzer")}
|
||||
className={styles.icon}
|
||||
path={navIconUrl("analyzer")}
|
||||
/>
|
||||
</LinkButton>
|
||||
{owner ? <BuildImageExportDialog build={build} owner={owner} /> : null}
|
||||
{description ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
icon={<MessageCircleMore />}
|
||||
className={styles.smallText}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</SendouPopover>
|
||||
) : null}
|
||||
{canEdit && (
|
||||
<>
|
||||
<LinkButton
|
||||
shape="circle"
|
||||
className={styles.smallText}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
to={`new?buildId=${id}&userId=${user!.id}`}
|
||||
testId="edit-build"
|
||||
icon={<SquarePen />}
|
||||
/>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("builds:deleteConfirm", { title })}
|
||||
fields={[
|
||||
["buildToDeleteId", id],
|
||||
["_action", "DELETE_BUILD"],
|
||||
]}
|
||||
>
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<Trash />}
|
||||
className={styles.smallText}
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildImageExportDialog({
|
||||
build,
|
||||
owner,
|
||||
}: {
|
||||
build: BuildProps["build"];
|
||||
owner: BuildGraphicOwner;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [showTitle, setShowTitle] = React.useState(true);
|
||||
const [showAbilityPoints, setShowAbilityPoints] = React.useState(true);
|
||||
const [showAbilityChunks, setShowAbilityChunks] = React.useState(false);
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
trigger={
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
icon={<HardDriveDownload />}
|
||||
className={styles.smallText}
|
||||
aria-label={t("common:imageExport.export")}
|
||||
/>
|
||||
}
|
||||
heading={t("common:imageExport.export")}
|
||||
filename={`build-${mySlugify(build.title)}`}
|
||||
qrCodePath={analyzerPage({
|
||||
weaponId: build.weapons[0].weaponSplId,
|
||||
abilities: build.abilities.flat(),
|
||||
})}
|
||||
settings={
|
||||
<>
|
||||
<SendouSwitch isSelected={showTitle} onChange={setShowTitle}>
|
||||
{t("common:imageExport.buildTitle")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showAbilityPoints}
|
||||
onChange={setShowAbilityPoints}
|
||||
>
|
||||
{t("common:imageExport.abilityPoints")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showAbilityChunks}
|
||||
onChange={setShowAbilityChunks}
|
||||
>
|
||||
{t("common:imageExport.abilityChunks")}
|
||||
</SendouSwitch>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BuildGraphic
|
||||
build={build}
|
||||
owner={owner}
|
||||
showTitle={showTitle}
|
||||
showAbilityPoints={showAbilityPoints}
|
||||
showAbilityChunks={showAbilityChunks}
|
||||
/>
|
||||
</ImageExportDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
|
||||
const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId);
|
||||
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
const slug = mySlugify(
|
||||
t(`weapons:MAIN_${normalizedWeaponSplId}`, { lng: "en" }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={weapon.weaponSplId} className={styles.weapon}>
|
||||
{weapon.isTop500 ? (
|
||||
<Image
|
||||
className={styles.top500}
|
||||
path={navIconUrl("xsearch")}
|
||||
alt=""
|
||||
height={24}
|
||||
width={24}
|
||||
testId="top500-crown"
|
||||
/>
|
||||
) : null}
|
||||
<Link to={weaponBuildPage(slug)}>
|
||||
<Image
|
||||
path={mainWeaponImageUrl(weapon.weaponSplId)}
|
||||
alt={t(`weapons:MAIN_${weapon.weaponSplId}`)}
|
||||
title={t(`weapons:MAIN_${weapon.weaponSplId}`)}
|
||||
height={36}
|
||||
width={36}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbilitiesRowWithGear({
|
||||
gearType,
|
||||
abilities,
|
||||
gearId,
|
||||
}: {
|
||||
gearType: GearType;
|
||||
abilities: AbilityType[];
|
||||
gearId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["gear"]);
|
||||
const translatedGearName = t(
|
||||
`gear:${gearTypeToInitial(gearType)}_${gearId}` as any,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof gearId === "number" ? (
|
||||
<Image
|
||||
height={64}
|
||||
width={64}
|
||||
alt={translatedGearName}
|
||||
title={translatedGearName}
|
||||
path={gearImageUrl(gearType, gearId)}
|
||||
className={styles.gear}
|
||||
/>
|
||||
) : null}
|
||||
{abilities.map((ability, i) => (
|
||||
<Ability key={i} ability={ability} size={i === 0 ? "MAIN" : "SUB"} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user