forked from OpenNeo/impress
Matchu
5e25e0bda6
Ok progress! We've moved the info about what bodies this fits, and what zones it occupies, into a Rails API endpoint that we now load at the same time as the other data! We'll keep moving more over, too!
695 lines
22 KiB
JavaScript
695 lines
22 KiB
JavaScript
import React from "react";
|
|
import { useQuery } from "@apollo/client";
|
|
import gql from "graphql-tag";
|
|
import {
|
|
AspectRatio,
|
|
Box,
|
|
Button,
|
|
Flex,
|
|
Grid,
|
|
IconButton,
|
|
Tooltip,
|
|
useColorModeValue,
|
|
usePrefersReducedMotion,
|
|
} from "@chakra-ui/react";
|
|
import { EditIcon, WarningIcon } from "@chakra-ui/icons";
|
|
import { MdPause, MdPlayArrow } from "react-icons/md";
|
|
|
|
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
|
import SpeciesColorPicker, {
|
|
useAllValidPetPoses,
|
|
getValidPoses,
|
|
getClosestPose,
|
|
} from "./components/SpeciesColorPicker";
|
|
import SpeciesFacesPicker, {
|
|
colorIsBasic,
|
|
} from "./ItemPage/SpeciesFacesPicker";
|
|
import {
|
|
itemAppearanceFragment,
|
|
petAppearanceFragment,
|
|
} from "./components/useOutfitAppearance";
|
|
import { useOutfitPreview } from "./components/OutfitPreview";
|
|
import { logAndCapture, useLocalStorage } from "./util";
|
|
import { useItemAppearances } from "./loaders/items";
|
|
|
|
function ItemPageOutfitPreview({ itemId }) {
|
|
const idealPose = React.useMemo(
|
|
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
|
|
[],
|
|
);
|
|
const [petState, setPetState] = React.useState({
|
|
// We'll fill these in once the canonical appearance data arrives.
|
|
speciesId: null,
|
|
colorId: null,
|
|
pose: null,
|
|
isValid: false,
|
|
|
|
// We use appearance ID, in addition to the above, to give the Apollo cache
|
|
// a really clear hint that the canonical pet appearance we preloaded is
|
|
// the exact right one to show! But switching species/color will null this
|
|
// out again, and that's okay. (We'll do an unnecessary reload if you
|
|
// switch back to it though... we could maybe do something clever there!)
|
|
appearanceId: null,
|
|
});
|
|
const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
|
|
"DTIItemPreviewPreferredSpeciesId",
|
|
null,
|
|
);
|
|
const [preferredColorId, setPreferredColorId] = useLocalStorage(
|
|
"DTIItemPreviewPreferredColorId",
|
|
null,
|
|
);
|
|
|
|
const setPetStateFromUserAction = React.useCallback(
|
|
(newPetState) =>
|
|
setPetState((prevPetState) => {
|
|
// When the user _intentionally_ chooses a species or color, save it in
|
|
// local storage for next time. (This won't update when e.g. their
|
|
// preferred species or color isn't available for this item, so we update
|
|
// to the canonical species or color automatically.)
|
|
//
|
|
// Re the "ifs", I have no reason to expect null to come in here, but,
|
|
// since this is touching client-persisted data, I want it to be even more
|
|
// reliable than usual!
|
|
if (
|
|
newPetState.speciesId &&
|
|
newPetState.speciesId !== prevPetState.speciesId
|
|
) {
|
|
setPreferredSpeciesId(newPetState.speciesId);
|
|
}
|
|
if (
|
|
newPetState.colorId &&
|
|
newPetState.colorId !== prevPetState.colorId
|
|
) {
|
|
if (colorIsBasic(newPetState.colorId)) {
|
|
// When the user chooses a basic color, don't index on it specifically,
|
|
// and instead reset to use default colors.
|
|
setPreferredColorId(null);
|
|
} else {
|
|
setPreferredColorId(newPetState.colorId);
|
|
}
|
|
}
|
|
|
|
return newPetState;
|
|
}),
|
|
[setPreferredColorId, setPreferredSpeciesId],
|
|
);
|
|
|
|
// We don't need to reload this query when preferred species/color change, so
|
|
// cache their initial values here to use as query arguments.
|
|
const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId);
|
|
const [initialPreferredColorId] = React.useState(preferredColorId);
|
|
|
|
const {
|
|
data: itemAppearancesData,
|
|
loading: loadingAppearances,
|
|
error: errorAppearances,
|
|
} = useItemAppearances(itemId);
|
|
const itemAppearances = itemAppearancesData || [];
|
|
|
|
// Start by loading the "canonical" pet and item appearance for the outfit
|
|
// preview. We'll use this to initialize both the preview and the picker.
|
|
//
|
|
// If the user has a preferred species saved from using the ItemPage in the
|
|
// past, we'll send that instead. This will return the appearance on that
|
|
// species if possible, or the default canonical species if not.
|
|
//
|
|
// TODO: If this is a non-standard pet color, like Mutant, we'll do an extra
|
|
// query after this loads, because our Apollo cache can't detect the
|
|
// shared item appearance. (For standard colors though, our logic to
|
|
// cover standard-color switches works for this preloading too.)
|
|
const {
|
|
loading: loadingGQL,
|
|
error: errorGQL,
|
|
data,
|
|
} = useQuery(
|
|
gql`
|
|
query ItemPageOutfitPreview(
|
|
$itemId: ID!
|
|
$preferredSpeciesId: ID
|
|
$preferredColorId: ID
|
|
) {
|
|
item(id: $itemId) {
|
|
id
|
|
name
|
|
restrictedZones {
|
|
id
|
|
label
|
|
}
|
|
canonicalAppearance(
|
|
preferredSpeciesId: $preferredSpeciesId
|
|
preferredColorId: $preferredColorId
|
|
) {
|
|
id
|
|
...ItemAppearanceForOutfitPreview
|
|
body {
|
|
id
|
|
canonicalAppearance(preferredColorId: $preferredColorId) {
|
|
id
|
|
species {
|
|
id
|
|
name
|
|
}
|
|
color {
|
|
id
|
|
}
|
|
pose
|
|
|
|
...PetAppearanceForOutfitPreview
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
${itemAppearanceFragment}
|
|
${petAppearanceFragment}
|
|
`,
|
|
{
|
|
variables: {
|
|
itemId,
|
|
preferredSpeciesId: initialPreferredSpeciesId,
|
|
preferredColorId: initialPreferredColorId,
|
|
},
|
|
onCompleted: (data) => {
|
|
const canonicalBody = data?.item?.canonicalAppearance?.body;
|
|
const canonicalPetAppearance = canonicalBody?.canonicalAppearance;
|
|
|
|
setPetState({
|
|
speciesId: canonicalPetAppearance?.species?.id,
|
|
colorId: canonicalPetAppearance?.color?.id,
|
|
pose: canonicalPetAppearance?.pose,
|
|
isValid: true,
|
|
appearanceId: canonicalPetAppearance?.id,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
const compatibleBodies = itemAppearances?.map(({ body }) => body) || [];
|
|
|
|
// If there's only one compatible body, and the canonical species's name
|
|
// appears in the item name, then this is probably a species-specific item,
|
|
// and we should adjust the UI to avoid implying that other species could
|
|
// model it.
|
|
const isProbablySpeciesSpecific =
|
|
compatibleBodies.length === 1 &&
|
|
compatibleBodies[0] !== "all" &&
|
|
(data?.item?.name || "")
|
|
.toLowerCase()
|
|
.includes(
|
|
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name.toLowerCase(),
|
|
);
|
|
const couldProbablyModelMoreData = !isProbablySpeciesSpecific;
|
|
|
|
// TODO: Does this double-trigger the HTTP request with SpeciesColorPicker?
|
|
const {
|
|
loading: loadingValids,
|
|
error: errorValids,
|
|
valids,
|
|
} = useAllValidPetPoses();
|
|
|
|
const [hasAnimations, setHasAnimations] = React.useState(false);
|
|
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
|
|
|
// This is like <OutfitPreview />, but we can use the appearance data, too!
|
|
const { appearance, preview } = useOutfitPreview({
|
|
speciesId: petState.speciesId,
|
|
colorId: petState.colorId,
|
|
pose: petState.pose,
|
|
appearanceId: petState.appearanceId,
|
|
wornItemIds: [itemId],
|
|
isLoading: loadingGQL || loadingValids,
|
|
spinnerVariant: "corner",
|
|
engine: "canvas",
|
|
onChangeHasAnimations: setHasAnimations,
|
|
});
|
|
|
|
// If there's an appearance loaded for this item, but it's empty, then the
|
|
// item is incompatible. (There should only be one item appearance: this one!)
|
|
const itemAppearance = appearance?.itemAppearances?.[0];
|
|
const itemLayers = itemAppearance?.layers || [];
|
|
const isCompatible = itemLayers.length > 0;
|
|
const usesHTML5 = itemLayers.every(layerUsesHTML5);
|
|
|
|
const onChange = React.useCallback(
|
|
({ speciesId, colorId }) => {
|
|
const validPoses = getValidPoses(valids, speciesId, colorId);
|
|
const pose = getClosestPose(validPoses, idealPose);
|
|
setPetStateFromUserAction({
|
|
speciesId,
|
|
colorId,
|
|
pose,
|
|
isValid: true,
|
|
appearanceId: null,
|
|
});
|
|
},
|
|
[valids, idealPose, setPetStateFromUserAction],
|
|
);
|
|
|
|
const borderColor = useColorModeValue("green.700", "green.400");
|
|
const errorColor = useColorModeValue("red.600", "red.400");
|
|
|
|
const error = errorGQL || errorAppearances || errorValids;
|
|
if (error) {
|
|
return <Box color="red.400">{error.message}</Box>;
|
|
}
|
|
|
|
return (
|
|
<Grid
|
|
templateAreas={{
|
|
base: `
|
|
"preview"
|
|
"speciesColorPicker"
|
|
"speciesFacesPicker"
|
|
"zones"
|
|
`,
|
|
md: `
|
|
"preview speciesFacesPicker"
|
|
"speciesColorPicker zones"
|
|
`,
|
|
}}
|
|
// HACK: Really I wanted 400px to match the natural height of the
|
|
// preview in md, but in Chromium that creates a scrollbar and
|
|
// 401px doesn't, not sure exactly why?
|
|
templateRows={{
|
|
base: "auto auto 200px auto",
|
|
md: "401px auto",
|
|
}}
|
|
templateColumns={{
|
|
base: "minmax(min-content, 400px)",
|
|
md: "minmax(min-content, 400px) fit-content(480px)",
|
|
}}
|
|
rowGap="4"
|
|
columnGap="6"
|
|
justifyContent="center"
|
|
width="100%"
|
|
>
|
|
<AspectRatio
|
|
gridArea="preview"
|
|
maxWidth="400px"
|
|
maxHeight="400px"
|
|
ratio="1"
|
|
border="1px"
|
|
borderColor={borderColor}
|
|
transition="border-color 0.2s"
|
|
borderRadius="lg"
|
|
boxShadow="lg"
|
|
overflow="hidden"
|
|
>
|
|
<Box>
|
|
{petState.isValid && preview}
|
|
<CustomizeMoreButton
|
|
speciesId={petState.speciesId}
|
|
colorId={petState.colorId}
|
|
pose={petState.pose}
|
|
itemId={itemId}
|
|
isDisabled={!petState.isValid}
|
|
/>
|
|
{hasAnimations && (
|
|
<PlayPauseButton
|
|
isPaused={isPaused}
|
|
onClick={() => setIsPaused(!isPaused)}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</AspectRatio>
|
|
<Flex gridArea="speciesColorPicker" alignSelf="start" align="center">
|
|
<Box
|
|
// This box grows at the same rate as the box on the right, so the
|
|
// middle box will be centered, if there's space!
|
|
flex="1 0 0"
|
|
/>
|
|
<SpeciesColorPicker
|
|
speciesId={petState.speciesId}
|
|
colorId={petState.colorId}
|
|
pose={petState.pose}
|
|
idealPose={idealPose}
|
|
onChange={(species, color, isValid, closestPose) => {
|
|
setPetStateFromUserAction({
|
|
speciesId: species.id,
|
|
colorId: color.id,
|
|
pose: closestPose,
|
|
isValid,
|
|
appearanceId: null,
|
|
});
|
|
}}
|
|
speciesIsDisabled={isProbablySpeciesSpecific}
|
|
size="sm"
|
|
showPlaceholders
|
|
/>
|
|
<Box flex="1 0 0" lineHeight="1" paddingLeft="1">
|
|
{
|
|
// Wait for us to start _requesting_ the appearance, and _then_
|
|
// for it to load, and _then_ check compatibility.
|
|
!loadingGQL &&
|
|
!loadingAppearances &&
|
|
!appearance.loading &&
|
|
petState.isValid &&
|
|
!isCompatible && (
|
|
<Tooltip
|
|
label={
|
|
couldProbablyModelMoreData
|
|
? "Item needs models"
|
|
: "Not compatible"
|
|
}
|
|
placement="top"
|
|
>
|
|
<WarningIcon
|
|
color={errorColor}
|
|
transition="color 0.2"
|
|
marginLeft="2"
|
|
borderRadius="full"
|
|
tabIndex="0"
|
|
_focus={{ outline: "none", boxShadow: "outline" }}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
</Box>
|
|
</Flex>
|
|
<Box
|
|
gridArea="speciesFacesPicker"
|
|
paddingTop="2"
|
|
overflow="auto"
|
|
padding="8px"
|
|
>
|
|
<SpeciesFacesPicker
|
|
selectedSpeciesId={petState.speciesId}
|
|
selectedColorId={petState.colorId}
|
|
compatibleBodies={compatibleBodies}
|
|
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
|
onChange={onChange}
|
|
isLoading={loadingGQL || loadingAppearances || loadingValids}
|
|
/>
|
|
</Box>
|
|
<Flex gridArea="zones" justifySelf="center" align="center">
|
|
{itemAppearances.length > 0 && (
|
|
<ItemZonesInfo
|
|
itemAppearances={itemAppearances}
|
|
restrictedZones={data?.item?.restrictedZones || []}
|
|
/>
|
|
)}
|
|
<Box width="6" />
|
|
<Flex
|
|
// Avoid layout shift while loading
|
|
minWidth="54px"
|
|
>
|
|
<HTML5Badge
|
|
usesHTML5={usesHTML5}
|
|
// If we're not compatible, act the same as if we're loading:
|
|
// don't change the badge, but don't show one yet if we don't
|
|
// have one yet.
|
|
isLoading={appearance.loading || !isCompatible}
|
|
/>
|
|
</Flex>
|
|
</Flex>
|
|
</Grid>
|
|
);
|
|
}
|
|
|
|
function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) {
|
|
const url =
|
|
`/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` +
|
|
`objects[]=${itemId}`;
|
|
|
|
// The default background is good in light mode, but in dark mode it's a
|
|
// very subtle transparent white... make it a semi-transparent black, for
|
|
// better contrast against light-colored background items!
|
|
const backgroundColor = useColorModeValue(undefined, "blackAlpha.700");
|
|
const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900");
|
|
|
|
return (
|
|
<LinkOrButton
|
|
href={isDisabled ? null : url}
|
|
role="group"
|
|
position="absolute"
|
|
top="2"
|
|
right="2"
|
|
size="sm"
|
|
background={backgroundColor}
|
|
_hover={{ backgroundColor: backgroundColorHover }}
|
|
_focus={{ backgroundColor: backgroundColorHover, boxShadow: "outline" }}
|
|
boxShadow="sm"
|
|
isDisabled={isDisabled}
|
|
>
|
|
<ExpandOnGroupHover paddingRight="2">Customize more</ExpandOnGroupHover>
|
|
<EditIcon />
|
|
</LinkOrButton>
|
|
);
|
|
}
|
|
|
|
function LinkOrButton({ href, ...props }) {
|
|
if (href != null) {
|
|
return <Button as="a" href={href} {...props} />;
|
|
} else {
|
|
return <Button {...props} />;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ExpandOnGroupHover starts at width=0, and expands to full width when a
|
|
* parent with role="group" gains hover or focus state.
|
|
*/
|
|
function ExpandOnGroupHover({ children, ...props }) {
|
|
const [measuredWidth, setMeasuredWidth] = React.useState(null);
|
|
const measurerRef = React.useRef(null);
|
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!measurerRef) {
|
|
// I don't think this is possible, but I'd like to know if it happens!
|
|
logAndCapture(
|
|
new Error(
|
|
`Measurer node not ready during effect. Transition won't be smooth.`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (measuredWidth != null) {
|
|
// Skip re-measuring when we already have a measured width. This is
|
|
// mainly defensive, to prevent the possibility of loops, even though
|
|
// this algorithm should be stable!
|
|
return;
|
|
}
|
|
|
|
const newMeasuredWidth = measurerRef.current.offsetWidth;
|
|
setMeasuredWidth(newMeasuredWidth);
|
|
}, [measuredWidth]);
|
|
|
|
return (
|
|
<Flex
|
|
// In block layout, the overflowing children would _also_ be constrained
|
|
// to width 0. But in flex layout, overflowing children _keep_ their
|
|
// natural size, so we can measure it even when not visible.
|
|
width="0"
|
|
overflow="hidden"
|
|
// Right-align the children, to keep the text feeling right-aligned when
|
|
// we expand. (To support left-side expansion, make this a prop!)
|
|
justify="flex-end"
|
|
// If the width somehow isn't measured yet, expand to width `auto`, which
|
|
// won't transition smoothly but at least will work!
|
|
_groupHover={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
|
_groupFocus={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
|
transition={!prefersReducedMotion && "width 0.2s"}
|
|
>
|
|
<Box ref={measurerRef} {...props}>
|
|
{children}
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
function PlayPauseButton({ isPaused, onClick }) {
|
|
return (
|
|
<IconButton
|
|
icon={isPaused ? <MdPlayArrow /> : <MdPause />}
|
|
aria-label={isPaused ? "Play" : "Pause"}
|
|
onClick={onClick}
|
|
borderRadius="full"
|
|
boxShadow="md"
|
|
color="gray.50"
|
|
backgroundColor="blackAlpha.700"
|
|
position="absolute"
|
|
bottom="2"
|
|
left="2"
|
|
_hover={{ backgroundColor: "blackAlpha.900" }}
|
|
_focus={{ backgroundColor: "blackAlpha.900" }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ItemZonesInfo({ itemAppearances, restrictedZones }) {
|
|
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
|
// merging zones with the same label, because that's how user-facing zone UI
|
|
// generally works!
|
|
const zoneLabelsAndTheirBodiesMap = {};
|
|
for (const { body, swfAssets } of itemAppearances) {
|
|
for (const { zone } of swfAssets) {
|
|
if (!zoneLabelsAndTheirBodiesMap[zone.label]) {
|
|
zoneLabelsAndTheirBodiesMap[zone.label] = {
|
|
zoneLabel: zone.label,
|
|
bodies: [],
|
|
};
|
|
}
|
|
zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body);
|
|
}
|
|
}
|
|
const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap);
|
|
|
|
const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
|
|
buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
|
|
buildSortKeyForZoneLabelsAndTheirBodies(b),
|
|
),
|
|
);
|
|
|
|
const restrictedZoneLabels = [
|
|
...new Set(restrictedZones.map((z) => z.label)),
|
|
].sort();
|
|
|
|
// We only show body info if there's more than one group of bodies to talk
|
|
// about. If they all have the same zones, it's clear from context that any
|
|
// preview available in the list has the zones listed here.
|
|
const bodyGroups = new Set(
|
|
zoneLabelsAndTheirBodies.map(({ bodies }) =>
|
|
bodies.map((b) => b.id).join(","),
|
|
),
|
|
);
|
|
const showBodyInfo = bodyGroups.size > 1;
|
|
|
|
return (
|
|
<Flex
|
|
fontSize="sm"
|
|
textAlign="center"
|
|
// If the text gets too long, wrap Restricts onto another line, and center
|
|
// them relative to each other.
|
|
wrap="wrap"
|
|
justify="center"
|
|
data-test-id="item-zones-info"
|
|
>
|
|
<Box flex="0 0 auto" maxWidth="100%">
|
|
<Box as="header" fontWeight="bold" display="inline">
|
|
Occupies:
|
|
</Box>{" "}
|
|
<Box as="ul" listStyleType="none" display="inline">
|
|
{sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => (
|
|
<Box
|
|
key={zoneLabel}
|
|
as="li"
|
|
display="inline"
|
|
_notLast={{ _after: { content: '", "' } }}
|
|
>
|
|
<Box
|
|
as="span"
|
|
// Don't wrap any of the list item content. But, by putting
|
|
// this in an extra container element, we _do_ allow wrapping
|
|
// _between_ list items.
|
|
whiteSpace="nowrap"
|
|
>
|
|
<ItemZonesInfoListItem
|
|
zoneLabel={zoneLabel}
|
|
bodies={bodies}
|
|
showBodyInfo={showBodyInfo}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
<Box width="4" flex="0 0 auto" />
|
|
<Box flex="0 0 auto" maxWidth="100%">
|
|
<Box as="header" fontWeight="bold" display="inline">
|
|
Restricts:
|
|
</Box>{" "}
|
|
{restrictedZoneLabels.length > 0 ? (
|
|
<Box as="ul" listStyleType="none" display="inline">
|
|
{restrictedZoneLabels.map((zoneLabel) => (
|
|
<Box
|
|
key={zoneLabel}
|
|
as="li"
|
|
display="inline"
|
|
_notLast={{ _after: { content: '", "' } }}
|
|
>
|
|
<Box
|
|
as="span"
|
|
// Don't wrap any of the list item content. But, by putting
|
|
// this in an extra container element, we _do_ allow wrapping
|
|
// _between_ list items.
|
|
whiteSpace="nowrap"
|
|
>
|
|
{zoneLabel}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
) : (
|
|
<Box as="span" fontStyle="italic" opacity="0.8">
|
|
N/A
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) {
|
|
let content = zoneLabel;
|
|
|
|
if (showBodyInfo) {
|
|
if (bodies.some((b) => b.representsAllBodies)) {
|
|
content = <>{content} (all species)</>;
|
|
} else {
|
|
// TODO: This is a bit reductive, if it's different for like special
|
|
// colors, e.g. Blue Acara vs Mutant Acara, this will just show
|
|
// "Acara" in either case! (We are at least gonna be defensive here
|
|
// and remove duplicates, though, in case both the Blue Acara and
|
|
// Mutant Acara body end up in the same list.)
|
|
const speciesNames = new Set(bodies.map((b) => b.species.name));
|
|
const speciesListString = [...speciesNames].sort().join(", ");
|
|
|
|
content = (
|
|
<>
|
|
{content}{" "}
|
|
<Tooltip
|
|
label={speciesListString}
|
|
textAlign="center"
|
|
placement="bottom"
|
|
>
|
|
<Box
|
|
as="span"
|
|
tabIndex="0"
|
|
_focus={{ outline: "none", boxShadow: "outline" }}
|
|
fontStyle="italic"
|
|
textDecoration="underline"
|
|
style={{ textDecorationStyle: "dotted" }}
|
|
opacity="0.8"
|
|
>
|
|
{/* Show the speciesNames count, even though it's less info,
|
|
* because it's more important that the tooltip content matches
|
|
* the count we show! */}
|
|
({speciesNames.size} species)
|
|
</Box>
|
|
</Tooltip>
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
|
|
// Sort by "represents all bodies", then by body count descending, then
|
|
// alphabetically.
|
|
const representsAllBodies = bodies.some((body) => body.representsAllBodies);
|
|
|
|
// To sort by body count _descending_, we subtract it from a large number.
|
|
// Then, to make it work in string comparison, we pad it with leading zeroes.
|
|
// Hacky but solid!
|
|
const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0");
|
|
|
|
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
|
}
|
|
|
|
export default ItemPageOutfitPreview;
|