diff --git a/src/app/ItemPage.js b/src/app/ItemPage.js index 5ac9812..4fe2bf4 100644 --- a/src/app/ItemPage.js +++ b/src/app/ItemPage.js @@ -18,6 +18,7 @@ import { Stack, Wrap, WrapItem, + Flex, } from "@chakra-ui/react"; import { CheckIcon, @@ -25,6 +26,7 @@ import { EditIcon, StarIcon, WarningIcon, + WarningTwoIcon, } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; import gql from "graphql-tag"; @@ -717,6 +719,7 @@ function ItemPageOutfitPreview({ itemId }) { @@ -785,22 +788,73 @@ function PlayPauseButton({ isPaused, onClick }) { function SpeciesFacesPicker({ selectedSpeciesId, + selectedColorId, compatibleBodies, couldProbablyModelMoreData, onChange, isLoading, }) { + // For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded + // data, which is part of the bundle and loads super-fast. For other colors, + // we load in all the faces of that color, falling back to basic colors when + // absent! + // + // TODO: Could we move this into our `build-cached-data` script, and just do + // the query all the time, and have Apollo happen to satisfy it fast? + // The semantics of returning our colorful random set could be weird… + const selectedColorIsBasic = ["8", "34", "61", "84"].includes( + selectedColorId + ); + const { loading: loadingGQL, error, data } = useQuery( + gql` + query SpeciesFacesPicker($selectedColorId: ID!) { + color(id: $selectedColorId) { + id + appliedToAllCompatibleSpecies { + id + neopetsImageHash + species { + id + } + body { + id + } + } + } + } + `, + { + variables: { selectedColorId }, + skip: selectedColorId == null || selectedColorIsBasic, + onError: (e) => console.error(e), + } + ); + const allBodiesAreCompatible = compatibleBodies.some( (body) => body.representsAllBodies ); const compatibleBodyIds = compatibleBodies.map((body) => body.id); - const allSpeciesFaces = speciesFaces.sort((a, b) => - a.speciesName.localeCompare(b.speciesName) - ); + const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || []; + + const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => { + const providedSpeciesFace = speciesFacesFromData.find( + (f) => f.species.id === defaultSpeciesFace.speciesId + ); + if (providedSpeciesFace && providedSpeciesFace.neopetsImageHash) { + return { + ...defaultSpeciesFace, + colorId: selectedColorId, + bodyId: providedSpeciesFace.body.id, + neopetsImageHash: providedSpeciesFace.neopetsImageHash, + }; + } else { + return defaultSpeciesFace; + } + }); return ( - <> + ))} + {error && ( + + + + Error loading this color's thumbnail images. +
+ Check your connection and try again. +
+
+ )} - +
); } @@ -1062,7 +1134,7 @@ function DeferredTooltip({ children, isOpen, ...props }) { // And it's not so bad if this gets out of sync with the database, // because the SpeciesColorPicker will still be usable! const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" }; -const speciesFaces = [ +const DEFAULT_SPECIES_FACES = [ { speciesName: "Acara", speciesId: "1", diff --git a/src/server/loaders.js b/src/server/loaders.js index 8ef97b2..aff55b9 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -742,6 +742,29 @@ const buildPetTypeBySpeciesAndColorLoader = (db, loaders) => { cacheKeyFn: ({ speciesId, colorId }) => `${speciesId},${colorId}` } ); +const buildPetTypesForColorLoader = (db, loaders) => + new DataLoader(async (colorIds) => { + const qs = colorIds.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM pet_types WHERE color_id IN (${qs})`, + colorIds + ); + + const entities = rows.map(normalizeRow); + + for (const petType of entities) { + loaders.petTypeLoader.prime(petType.id, petType); + loaders.petTypeBySpeciesAndColorLoader.prime( + { speciesId: petType.speciesId, colorId: petType.colorId }, + petType + ); + } + + return colorIds.map((colorId) => + entities.filter((e) => e.colorId === colorId) + ); + }); + const buildSwfAssetLoader = (db) => new DataLoader(async (swfAssetIds) => { const qs = swfAssetIds.map((_) => "?").join(","); @@ -1271,6 +1294,7 @@ function buildLoaders(db) { db, loaders ); + loaders.petTypesForColorLoader = buildPetTypesForColorLoader(db, loaders); loaders.swfAssetLoader = buildSwfAssetLoader(db); loaders.swfAssetCountLoader = buildSwfAssetCountLoader(db); loaders.swfAssetByRemoteIdLoader = buildSwfAssetByRemoteIdLoader(db); diff --git a/src/server/types/PetAppearance.js b/src/server/types/PetAppearance.js index 9ca54de..41cd61f 100644 --- a/src/server/types/PetAppearance.js +++ b/src/server/types/PetAppearance.js @@ -14,6 +14,9 @@ const typeDefs = gql` id: ID! name: String! isStandard: Boolean! + + # All SpeciesColorPairs of this color. + appliedToAllCompatibleSpecies: [SpeciesColorPair!]! @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneDay}) } type Species @cacheControl(maxAge: ${oneWeek}) { @@ -68,6 +71,22 @@ const typeDefs = gql` isGlitched: Boolean! } + # Like a PetAppearance, but with no pose specified. Species and color are + # enough info to specify a body; and the neopetsImageHash values we save + # don't have gender presentation specified, anyway, so they're available + # here. + type SpeciesColorPair { + id: ID! + species: Species! + color: Color! + body: Body! + + # A hash to use in a pets.neopets.com image URL. Might be null if we don't + # have one for this pair, which is uncommon - but it's _somewhat_ common + # for them to have clothes, if we've never seen a plain version modeled. + neopetsImageHash: String + } + extend type Query { color(id: ID!): Color allColors: [Color!]! @cacheControl(maxAge: ${oneHour}, staleWhileRevalidate: ${oneWeek}) @@ -98,6 +117,15 @@ const resolvers = { const color = await colorLoader.load(id); return color.standard ? true : false; }, + appliedToAllCompatibleSpecies: async ( + { id }, + _, + { petTypesForColorLoader } + ) => { + const petTypes = await petTypesForColorLoader.load(id); + const speciesColorPairs = petTypes.map((petType) => ({ id: petType.id })); + return speciesColorPairs; + }, }, Species: { @@ -193,6 +221,32 @@ const resolvers = { }, }, + SpeciesColorPair: { + species: async ({ id }, _, { petTypeLoader }) => { + const petType = await petTypeLoader.load(id); + return { id: petType.speciesId }; + }, + color: async ({ id }, _, { petTypeLoader }) => { + const petType = await petTypeLoader.load(id); + return { id: petType.colorId }; + }, + body: async ({ id }, _, { petTypeLoader }) => { + const petType = await petTypeLoader.load(id); + return { id: petType.bodyId }; + }, + neopetsImageHash: async ({ id }, _, { petTypeLoader }) => { + const petType = await petTypeLoader.load(id); + + // `basicImageHash` is guaranteed to be a plain no-clothes image, whereas + // `imageHash` prefers to be if possible, but might not be. (I forget the + // details on how this was implemented in Classic, so I'm not _sure_ on + // `imageHash` preferences during modeling… but I'm confident that + // `basicImageHash` is always better, and `imageHash` is better than + // nothing!) + return petType.basicImageHash || petType.imageHash; + }, + }, + Query: { color: async (_, { id }, { colorLoader }) => { const color = await colorLoader.load(id);