diff --git a/.gitignore b/.gitignore index 11fee36c..baa53f43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,16 @@ log/*.log tmp/**/* .env .vagrant + +/app/assets/builds/* +!/app/assets/builds/.keep + +/node_modules + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/Gemfile b/Gemfile index 0d244e80..3d39928e 100644 --- a/Gemfile +++ b/Gemfile @@ -78,3 +78,5 @@ group :test do gem 'factory_girl_rails', '~> 4.9' gem 'rspec-rails', '~> 2.0.0.beta.22' end + +gem "jsbundling-rails", "~> 1.1" diff --git a/Gemfile.lock b/Gemfile.lock index 1d4d54c9..6a054bb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,6 +150,8 @@ GEM http_accept_language (2.1.1) i18n (1.14.1) concurrent-ruby (~> 1.0) + jsbundling-rails (1.1.2) + railties (>= 6.0.0) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) @@ -329,6 +331,7 @@ DEPENDENCIES globalize (~> 6.2, >= 6.2.1) haml (~> 6.1, >= 6.1.1) http_accept_language (~> 2.1, >= 2.1.1) + jsbundling-rails (~> 1.1) letter_opener (~> 1.8, >= 1.8.1) memcache-client (~> 1.8.5) mysql2 (~> 0.5.5) @@ -354,4 +357,4 @@ RUBY VERSION ruby 3.1.4p223 BUNDLED WITH - 2.4.18 + 2.3.26 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 00000000..faaa7582 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server +js: yarn build --watch diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/javascript/wardrobe-2020-page.js b/app/javascript/wardrobe-2020-page.js new file mode 100644 index 00000000..1d31ac65 --- /dev/null +++ b/app/javascript/wardrobe-2020-page.js @@ -0,0 +1,3 @@ +import { WardrobePage } from "./wardrobe-2020"; + +console.log("Hello, wardrobe page!", WardrobePage); diff --git a/app/javascript/wardrobe-2020/ItemPage.js b/app/javascript/wardrobe-2020/ItemPage.js new file mode 100644 index 00000000..3a7bfc45 --- /dev/null +++ b/app/javascript/wardrobe-2020/ItemPage.js @@ -0,0 +1,1442 @@ +import React from "react"; +import { ClassNames } from "@emotion/react"; +import { + AspectRatio, + Button, + Box, + HStack, + IconButton, + SkeletonText, + Tooltip, + VisuallyHidden, + VStack, + useBreakpointValue, + useColorModeValue, + useTheme, + useToast, + Flex, + usePrefersReducedMotion, + Grid, + Popover, + PopoverContent, + PopoverTrigger, + Checkbox, +} from "@chakra-ui/react"; +import { + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EditIcon, + StarIcon, + WarningIcon, +} from "@chakra-ui/icons"; +import { MdPause, MdPlayArrow } from "react-icons/md"; +import gql from "graphql-tag"; +import { useQuery, useMutation } from "@apollo/client"; +import Link from "next/link"; + +import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout"; +import { + Delay, + logAndCapture, + MajorErrorMessage, + useLocalStorage, +} from "./util"; +import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge"; +import { + itemAppearanceFragment, + petAppearanceFragment, +} from "./components/useOutfitAppearance"; +import { useOutfitPreview } from "./components/OutfitPreview"; +import SpeciesColorPicker, { + useAllValidPetPoses, + getValidPoses, + getClosestPose, +} from "./components/SpeciesColorPicker"; +import useCurrentUser from "./components/useCurrentUser"; +import SpeciesFacesPicker, { + colorIsBasic, +} from "./ItemPage/SpeciesFacesPicker"; +import { useRouter } from "next/router"; +import Head from "next/head"; + +function ItemPage() { + const { query } = useRouter(); + return ; +} + +/** + * ItemPageContent is the content of ItemPage, but we also use it as the + * entry point for ItemPageDrawer! When embedded in ItemPageDrawer, the + * `isEmbedded` prop is true, so we know not to e.g. set the page title. + */ +export function ItemPageContent({ itemId, isEmbedded = false }) { + const { isLoggedIn } = useCurrentUser(); + + const { error, data } = useQuery( + gql` + query ItemPage($itemId: ID!) { + item(id: $itemId) { + id + name + isNc + isPb + thumbnailUrl + description + createdAt + ncTradeValueText + + # For Support users. + rarityIndex + isManuallyNc + } + } + `, + { variables: { itemId }, returnPartialData: true } + ); + + if (error) { + return ; + } + + const item = data?.item; + + return ( + <> + {!isEmbedded && item?.name && ( + + {item?.name} | Dress to Impress + + )} + + + + + + {isLoggedIn && } + + {!isEmbedded && } + + + + ); +} + +function ItemPageDescription({ description, isEmbedded }) { + // Show 2 lines of description text placeholder on small screens, or when + // embedded in the wardrobe page's narrow drawer. In larger contexts, show + // just 1 line. + const viewportNumDescriptionLines = useBreakpointValue({ base: 2, md: 1 }); + const numDescriptionLines = isEmbedded ? 2 : viewportNumDescriptionLines; + + return ( + + {description ? ( + description + ) : description === "" ? ( + (This item has no description.) + ) : ( + + + + + + )} + + ); +} + +const ITEM_PAGE_OWN_WANT_BUTTONS_QUERY = gql` + query ItemPageOwnWantButtons($itemId: ID!) { + item(id: $itemId) { + id + name + currentUserOwnsThis + currentUserWantsThis + } + currentUser { + closetLists { + id + name + isDefaultList + ownsOrWantsItems + hasItem(itemId: $itemId) + } + } + } +`; + +function ItemPageOwnWantButtons({ itemId }) { + const { loading, error, data } = useQuery(ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, { + variables: { itemId }, + context: { sendAuth: true }, + }); + + if (error) { + return {error.message}; + } + + const closetLists = data?.currentUser?.closetLists || []; + const realLists = closetLists.filter((cl) => !cl.isDefaultList); + const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS"); + const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS"); + + return ( + + + + + = 1} + popoverPlacement="bottom-end" + /> + + + + + = 1} + popoverPlacement="bottom-start" + /> + + ); +} + +function ItemPageOwnWantListsDropdown({ + closetLists, + item, + isVisible, + popoverPlacement, +}) { + return ( + + + + + + + + + ); +} + +const ItemPageOwnWantListsDropdownButton = React.forwardRef( + ({ closetLists, isVisible, ...props }, ref) => { + const listsToShow = closetLists.filter((cl) => cl.hasItem); + + let buttonText; + if (listsToShow.length === 1) { + buttonText = `In list: "${listsToShow[0].name}"`; + } else if (listsToShow.length > 1) { + const listNames = listsToShow.map((cl) => `"${cl.name}"`).join(", "); + buttonText = `${listsToShow.length} lists: ${listNames}`; + } else { + buttonText = "Add to list"; + } + + return ( + + ); + } +); + +function ItemPageOwnWantListsDropdownContent({ closetLists, item }) { + return ( + + {closetLists.map((closetList) => ( + + + + ))} + + ); +} + +function ItemPageOwnWantsListsDropdownRow({ closetList, item }) { + const toast = useToast(); + + const [sendAddToListMutation] = useMutation( + gql` + mutation ItemPage_AddToClosetList($listId: ID!, $itemId: ID!) { + addItemToClosetList( + listId: $listId + itemId: $itemId + removeFromDefaultList: true + ) { + id + hasItem(itemId: $itemId) + } + } + `, + { context: { sendAuth: true } } + ); + + const [sendRemoveFromListMutation] = useMutation( + gql` + mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) { + removeItemFromClosetList( + listId: $listId + itemId: $itemId + ensureInSomeList: true + ) { + id + hasItem(itemId: $itemId) + } + } + `, + { context: { sendAuth: true } } + ); + + const onChange = React.useCallback( + (e) => { + if (e.target.checked) { + sendAddToListMutation({ + variables: { listId: closetList.id, itemId: item.id }, + optimisticResponse: { + addItemToClosetList: { + __typename: "ClosetList", + id: closetList.id, + hasItem: true, + }, + }, + }).catch((error) => { + console.error(error); + toast({ + status: "error", + title: `Oops, error adding "${item.name}" to "${closetList.name}!"`, + description: + "Check your connection and try again? Sorry about this!", + }); + }); + } else { + sendRemoveFromListMutation({ + variables: { listId: closetList.id, itemId: item.id }, + optimisticResponse: { + removeItemFromClosetList: { + __typename: "ClosetList", + id: closetList.id, + hasItem: false, + }, + }, + }).catch((error) => { + console.error(error); + toast({ + status: "error", + title: `Oops, error removing "${item.name}" from "${closetList.name}!"`, + description: + "Check your connection and try again? Sorry about this!", + }); + }); + } + }, + [closetList, item, sendAddToListMutation, sendRemoveFromListMutation, toast] + ); + + return ( + + {closetList.name} + + ); +} + +function ItemPageOwnButton({ itemId, isChecked }) { + const theme = useTheme(); + const toast = useToast(); + + const [sendAddMutation] = useMutation( + gql` + mutation ItemPageOwnButtonAdd($itemId: ID!) { + addToItemsCurrentUserOwns(itemId: $itemId) { + id + currentUserOwnsThis + } + } + `, + { + variables: { itemId }, + context: { sendAuth: true }, + optimisticResponse: { + __typename: "Mutation", + addToItemsCurrentUserOwns: { + __typename: "Item", + id: itemId, + currentUserOwnsThis: true, + }, + }, + // TODO: Refactor the mutation result to include closet lists + refetchQueries: [ + { + query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, + variables: { itemId }, + context: { sendAuth: true }, + }, + ], + } + ); + + const [sendRemoveMutation] = useMutation( + gql` + mutation ItemPageOwnButtonRemove($itemId: ID!) { + removeFromItemsCurrentUserOwns(itemId: $itemId) { + id + currentUserOwnsThis + } + } + `, + { + variables: { itemId }, + context: { sendAuth: true }, + optimisticResponse: { + __typename: "Mutation", + removeFromItemsCurrentUserOwns: { + __typename: "Item", + id: itemId, + currentUserOwnsThis: false, + }, + }, + // TODO: Refactor the mutation result to include closet lists + refetchQueries: [ + { + query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, + variables: { itemId }, + context: { sendAuth: true }, + }, + ], + } + ); + + return ( + + {({ css }) => ( + + { + if (e.target.checked) { + sendAddMutation().catch((e) => { + console.error(e); + toast({ + title: "We had trouble adding this to the items you own.", + description: + "Check your internet connection, and try again.", + status: "error", + duration: 5000, + }); + }); + } else { + sendRemoveMutation().catch((e) => { + console.error(e); + toast({ + title: + "We had trouble removing this from the items you own.", + description: + "Check your internet connection, and try again.", + status: "error", + duration: 5000, + }); + }); + } + }} + /> + + + )} + + ); +} + +function ItemPageWantButton({ itemId, isChecked }) { + const theme = useTheme(); + const toast = useToast(); + + const [sendAddMutation] = useMutation( + gql` + mutation ItemPageWantButtonAdd($itemId: ID!) { + addToItemsCurrentUserWants(itemId: $itemId) { + id + currentUserWantsThis + } + } + `, + { + variables: { itemId }, + context: { sendAuth: true }, + optimisticResponse: { + __typename: "Mutation", + addToItemsCurrentUserWants: { + __typename: "Item", + id: itemId, + currentUserWantsThis: true, + }, + }, + // TODO: Refactor the mutation result to include closet lists + refetchQueries: [ + { + query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, + variables: { itemId }, + context: { sendAuth: true }, + }, + ], + } + ); + + const [sendRemoveMutation] = useMutation( + gql` + mutation ItemPageWantButtonRemove($itemId: ID!) { + removeFromItemsCurrentUserWants(itemId: $itemId) { + id + currentUserWantsThis + } + } + `, + { + variables: { itemId }, + context: { sendAuth: true }, + optimisticResponse: { + __typename: "Mutation", + removeFromItemsCurrentUserWants: { + __typename: "Item", + id: itemId, + currentUserWantsThis: false, + }, + }, + // TODO: Refactor the mutation result to include closet lists + refetchQueries: [ + { + query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, + variables: { itemId }, + context: { sendAuth: true }, + }, + ], + } + ); + + return ( + + {({ css }) => ( + + { + if (e.target.checked) { + sendAddMutation().catch((e) => { + console.error(e); + toast({ + title: "We had trouble adding this to the items you want.", + description: + "Check your internet connection, and try again.", + status: "error", + duration: 5000, + }); + }); + } else { + sendRemoveMutation().catch((e) => { + console.error(e); + toast({ + title: + "We had trouble removing this from the items you want.", + description: + "Check your internet connection, and try again.", + status: "error", + duration: 5000, + }); + }); + } + }} + /> + + + )} + + ); +} + +function ItemPageTradeLinks({ itemId, isEmbedded }) { + const { data, loading, error } = useQuery( + gql` + query ItemPageTradeLinks($itemId: ID!) { + item(id: $itemId) { + id + numUsersOfferingThis + numUsersSeekingThis + } + } + `, + { variables: { itemId } } + ); + + if (error) { + return {error.message}; + } + + return ( + + + Trading: + + + + + + + + + ); +} + +function ItemPageTradeLink({ href, count, label, colorScheme, isEmbedded }) { + return ( + + + + ); +} + +function IconCheckbox({ icon, isChecked, ...props }) { + return ( + + + + {icon} + + + ); +} + +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); + + // 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 @client + } + compatibleBodiesAndTheirZones { + body { + id + representsAllBodies + species { + id + name + } + } + zones { + id + label @client + } + } + 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 = + data?.item?.compatibleBodiesAndTheirZones?.map(({ body }) => body) || []; + const compatibleBodiesAndTheirZones = + data?.item?.compatibleBodiesAndTheirZones || []; + + // 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].representsAllBodies && + (data?.item?.name || "").includes( + data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name + ); + 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 , 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 || errorValids; + if (error) { + return {error.message}; + } + + return ( + + + + {petState.isValid && preview} + + {hasAnimations && ( + setIsPaused(!isPaused)} + /> + )} + + + + + { + setPetStateFromUserAction({ + speciesId: species.id, + colorId: color.id, + pose: closestPose, + isValid, + appearanceId: null, + }); + }} + speciesIsDisabled={isProbablySpeciesSpecific} + size="sm" + showPlaceholders + /> + + { + // Wait for us to start _requesting_ the appearance, and _then_ + // for it to load, and _then_ check compatibility. + !loadingGQL && + !appearance.loading && + petState.isValid && + !isCompatible && ( + + + + ) + } + + + + + + + {compatibleBodiesAndTheirZones.length > 0 && ( + + )} + + + + + + + ); +} + +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 ( + + Customize more + + + ); +} + +function LinkOrButton({ href, ...props }) { + if (href != null) { + return ( + + + ); + } + + if (isSaving) { + return ( + + + Saving… + + ); + } + + if (latestVersionIsSaved) { + return ( + + + Saved + + ); + } + + if (saveError) { + return ( + + + Error saving + + ); + } + + // The most common way we'll hit this null is when the outfit is changing, + // but the debouncing isn't done yet, so it's not saving yet. + return null; +} + +/** + * OutfitHeading is an editable outfit name, as a big pretty page heading! + * It also contains the outfit menu, for saving etc. + */ +function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { + const { canDeleteOutfit } = outfitSaving; + const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true }); + + return ( + // The Editable wraps everything, including the menu, because the menu has + // a Rename option. + + dispatchToOutfit({ type: "rename", outfitName: value }) + } + > + {({ onEdit }) => ( + + + + + + + + + + + + + + + + } + aria-label="Outfit menu" + borderRadius="full" + fontSize="24px" + opacity="0.8" + /> + + + {outfitState.id && ( + } + as="a" + href={outfitCopyUrl} + target="_blank" + > + Edit a copy + + )} + } + onClick={() => { + // Start the rename after a tick, so finishing up the click + // won't just immediately remove focus from the Editable. + setTimeout(onEdit, 0); + }} + > + Rename + + {canDeleteOutfit && ( + + )} + + + + + )} + + ); +} + +function DeleteOutfitMenuItem({ outfitState }) { + const { id, name } = outfitState; + const { isOpen, onOpen, onClose } = useDisclosure(); + const { push: pushHistory } = useRouter(); + + const [sendDeleteOutfitMutation, { loading, error }] = useMutation( + gql` + mutation DeleteOutfitMenuItem($id: ID!) { + deleteOutfit(id: $id) + } + `, + { + context: { sendAuth: true }, + update(cache) { + // Once this is deleted, evict it from the local cache, and "garbage + // collect" to force all queries referencing this outfit to reload the + // next time we see them. (This is especially important since we're + // about to redirect to the user outfits page, which shouldn't show + // the outfit anymore!) + cache.evict(`Outfit:${id}`); + cache.gc(); + }, + } + ); + + return ( + <> + } onClick={onOpen}> + Delete + + + + + Delete outfit "{name}"? + + + We'll delete this data and remove it from your list of outfits. + Links and image embeds pointing to this outfit will break. Is that + okay? + {error && ( + + Error deleting outfit: "{getGraphQLErrorMessage(error)}". Try + again? + + )} + + + + + + + + + + ); +} + +/** + * fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the + * fade-out and height decrease when an Item or ItemZoneGroup is removed. + * + * Note that this _cannot_ be implemented as a wrapper component that returns a + * CSSTransition. This is because the CSSTransition must be the direct child of + * the TransitionGroup, and a wrapper breaks the parent-child relationship. + * + * See react-transition-group docs for more info! + */ +const fadeOutAndRollUpTransition = (css) => ({ + classNames: css` + &-exit { + opacity: 1; + height: auto; + } + + &-exit-active { + opacity: 0; + height: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; + transition: all 0.5s; + } + `, + timeout: 500, + onExit: (e) => { + e.style.height = e.offsetHeight + "px"; + }, +}); + +export default ItemsPanel; diff --git a/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js b/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js new file mode 100644 index 00000000..a9c69697 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js @@ -0,0 +1,683 @@ +import React from "react"; +import { ClassNames } from "@emotion/react"; +import { + Box, + Button, + DarkMode, + Flex, + FormControl, + FormHelperText, + FormLabel, + HStack, + IconButton, + ListItem, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Portal, + Stack, + Switch, + Tooltip, + UnorderedList, + useClipboard, + useToast, +} from "@chakra-ui/react"; +import { + ArrowBackIcon, + CheckIcon, + ChevronDownIcon, + DownloadIcon, + LinkIcon, + SettingsIcon, +} from "@chakra-ui/icons"; +import { MdPause, MdPlayArrow } from "react-icons/md"; +import Link from "next/link"; + +import { getBestImageUrlForLayer } from "../components/OutfitPreview"; +import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge"; +import PosePicker from "./PosePicker"; +import SpeciesColorPicker from "../components/SpeciesColorPicker"; +import { loadImage, useLocalStorage } from "../util"; +import useCurrentUser from "../components/useCurrentUser"; +import useOutfitAppearance from "../components/useOutfitAppearance"; +import OutfitKnownGlitchesBadge from "./OutfitKnownGlitchesBadge"; +import usePreferArchive from "../components/usePreferArchive"; + +/** + * OutfitControls is the set of controls layered over the outfit preview, to + * control things like species/color and sharing links! + */ +function OutfitControls({ + outfitState, + dispatchToOutfit, + showAnimationControls, + appearance, +}) { + const [focusIsLocked, setFocusIsLocked] = React.useState(false); + const onLockFocus = React.useCallback( + () => setFocusIsLocked(true), + [setFocusIsLocked] + ); + const onUnlockFocus = React.useCallback( + () => setFocusIsLocked(false), + [setFocusIsLocked] + ); + + // HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily, + // which triggers unnecessary rebuilds of the `onSpeciesColorChange` + // callback, which causes the `React.memo` on `SpeciesColorPicker` to + // fail, which harms performance. But it seems to work just fine if we + // hold onto the first copy of the function we get! :/ + const _toast = useToast(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const toast = React.useMemo(() => _toast, []); + + const onSpeciesColorChange = React.useCallback( + (species, color, isValid, closestPose) => { + if (isValid) { + dispatchToOutfit({ + type: "setSpeciesAndColor", + speciesId: species.id, + colorId: color.id, + pose: closestPose, + }); + } else { + // NOTE: This shouldn't be possible to trigger, because the + // `stateMustAlwaysBeValid` prop should prevent it. But we have + // it as a fallback, just in case! + toast({ + title: `We haven't seen a ${color.name} ${species.name} before! 😓`, + status: "warning", + }); + } + }, + [dispatchToOutfit, toast] + ); + + const maybeUnlockFocus = (e) => { + // We lock focus when a touch-device user taps the area. When they tap + // empty space, we treat that as a toggle and release the focus lock. + if (e.target === e.currentTarget) { + onUnlockFocus(); + } + }; + + return ( + + {({ css, cx }) => ( + { + const opacity = parseFloat( + getComputedStyle(e.currentTarget).opacity + ); + if (opacity < 0.5) { + // If the controls aren't visible right now, then clicks on them are + // probably accidental. Ignore them! (We prevent default to block + // built-in behaviors like link nav, and we stop propagation to block + // our own custom click handlers. I don't know if I can prevent the + // select clicks though?) + e.preventDefault(); + e.stopPropagation(); + + // We also show the controls, by locking focus. We'll undo this when + // the user taps elsewhere (because it will trigger a blur event from + // our child components), in `maybeUnlockFocus`. + setFocusIsLocked(true); + } + }} + onContextMenuCapture={() => { + if (!toast.isActive("outfit-controls-context-menu-hint")) { + toast({ + id: "outfit-controls-context-menu-hint", + title: + "By the way, to save this image, use the Download button!", + description: "It's in the top right of the preview area.", + duration: 10000, + isClosable: true, + }); + } + }} + data-test-id="wardrobe-outfit-controls" + > + + + + + + {showAnimationControls && } + + + + + + + + + + + + + + + + + {outfitState.speciesId && outfitState.colorId && ( + + {/** + * We try to center the species/color picker, but the left spacer will + * shrink more than the pose picker container if we run out of space! + */} + + + + + + + + + + + )} + + )} + + ); +} + +function OutfitHTML5Badge({ appearance }) { + const petIsUsingHTML5 = + appearance.petAppearance?.layers.every(layerUsesHTML5); + + const itemsNotUsingHTML5 = appearance.items.filter((item) => + item.appearance.layers.some((l) => !layerUsesHTML5(l)) + ); + itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name)); + + const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0; + + let tooltipLabel; + if (usesHTML5) { + tooltipLabel = ( + <>This outfit is converted to HTML5, and ready to use on Neopets.com! + ); + } else { + tooltipLabel = ( + + + This outfit isn't converted to HTML5 yet, so it might not appear in + Neopets.com customization yet. Once it's ready, it could look a bit + different than our temporary preview here. It might even be animated! + + {!petIsUsingHTML5 && ( + + This pet is not yet converted. + + )} + {itemsNotUsingHTML5.length > 0 && ( + <> + + The following items aren't yet converted: + + + {itemsNotUsingHTML5.map((item) => ( + {item.name} + ))} + + + )} + + ); + } + + return ( + + ); +} + +/** + * BackButton takes you back home, or to Your Outfits if this outfit is yours. + */ +function BackButton({ outfitState }) { + const currentUser = useCurrentUser(); + const outfitBelongsToCurrentUser = + outfitState.creator && outfitState.creator.id === currentUser.id; + + return ( + + } + aria-label="Leave this outfit" + d="inline-flex" // Not sure why requires this to style right! ^^` + data-test-id="wardrobe-nav-back-button" + /> + + ); +} + +/** + * DownloadButton downloads the outfit as an image! + */ +function DownloadButton({ outfitState }) { + const { visibleLayers } = useOutfitAppearance(outfitState); + + const [downloadImageUrl, prepareDownload] = + useDownloadableImage(visibleLayers); + + return ( + + + } + aria-label="Download" + as="a" + // eslint-disable-next-line no-script-url + href={downloadImageUrl || "#"} + onClick={(e) => { + if (!downloadImageUrl) { + e.preventDefault(); + } + }} + download={(outfitState.name || "Outfit") + ".png"} + onMouseEnter={prepareDownload} + onFocus={prepareDownload} + cursor={!downloadImageUrl && "wait"} + /> + + + ); +} + +/** + * CopyLinkButton copies the outfit URL to the clipboard! + */ +function CopyLinkButton({ outfitState }) { + const { onCopy, hasCopied } = useClipboard(outfitState.url); + + return ( + + + : } + aria-label="Copy link" + onClick={onCopy} + /> + + + ); +} + +function PlayPauseButton() { + const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); + + // We show an intro animation if this mounts while paused. Whereas if we're + // not paused, we initialize as if we had already finished. + const [blinkInState, setBlinkInState] = React.useState( + isPaused ? { type: "ready" } : { type: "done" } + ); + const buttonRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (blinkInState.type === "ready" && buttonRef.current) { + setBlinkInState({ + type: "started", + position: { + left: buttonRef.current.offsetLeft, + top: buttonRef.current.offsetTop, + }, + }); + } + }, [blinkInState, setBlinkInState]); + + return ( + + {({ css }) => ( + <> + + {blinkInState.type === "started" && ( + + setBlinkInState({ type: "done" })} + // Don't disrupt the hover state of the controls! (And the button + // doesn't seem to click correctly, not sure why, but instead of + // debugging I'm adding this :p) + pointerEvents="none" + className={css` + @keyframes fade-in-out { + 0% { + opacity: 0; + } + + 10% { + opacity: 1; + } + + 90% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + + opacity: 0; + animation: fade-in-out 2s; + `} + /> + + )} + + )} + + ); +} + +const PlayPauseButtonContent = React.forwardRef( + ({ isPaused, setIsPaused, ...props }, ref) => { + return ( + : } + onClick={() => setIsPaused(!isPaused)} + {...props} + > + {isPaused ? <>Paused : <>Playing} + + ); + } +); + +function SettingsButton({ onLockFocus, onUnlockFocus }) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function HiResModeSetting() { + const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false); + const [preferArchive, setPreferArchive] = usePreferArchive(); + + return ( + + + + + + Hi-res mode (SVG) + + + Crisper at higher resolutions, but not always accurate + + + + setHiResMode(e.target.checked)} + /> + + + + + + + + Use DTI's image archive + + + Turn this on when images.neopets.com is slow! + + + + setPreferArchive(e.target.checked)} + /> + + + + ); +} + +const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => { + return ( + + ); +}); + +/** + * ControlButton is a UI helper to render the cute round buttons we use in + * OutfitControls! + */ +function ControlButton({ icon, "aria-label": ariaLabel, ...props }) { + return ( + + ); +} + +/** + * useDownloadableImage loads the image data and generates the downloadable + * image URL. + */ +function useDownloadableImage(visibleLayers) { + const [hiResMode] = useLocalStorage("DTIHiResMode", false); + const [preferArchive] = usePreferArchive(); + + const [downloadImageUrl, setDownloadImageUrl] = React.useState(null); + const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]); + const toast = useToast(); + + const prepareDownload = React.useCallback(async () => { + // Skip if the current image URL is already correct for these layers. + const layerIds = visibleLayers.map((l) => l.id); + if (layerIds.join(",") === preparedForLayerIds.join(",")) { + return; + } + + // Skip if there are no layers. (This probably means we're still loading!) + if (layerIds.length === 0) { + return; + } + + setDownloadImageUrl(null); + + // NOTE: You could argue that we may as well just always use PNGs here, + // regardless of hi-res mode… but using the same src will help both + // performance (can use cached SVG), and predictability (image will + // look like what you see here). + const imagePromises = visibleLayers.map((layer) => + loadImage(getBestImageUrlForLayer(layer, { hiResMode }), { + crossOrigin: "anonymous", + preferArchive, + }) + ); + + let images; + try { + images = await Promise.all(imagePromises); + } catch (e) { + console.error("Error building downloadable image", e); + toast({ + status: "error", + title: "Oops, sorry, we couldn't download the image!", + description: + "Check your connection, then reload the page and try again.", + }); + return; + } + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + canvas.width = 600; + canvas.height = 600; + + for (const image of images) { + context.drawImage(image, 0, 0); + } + + console.debug( + "Generated image for download", + layerIds, + canvas.toDataURL("image/png") + ); + setDownloadImageUrl(canvas.toDataURL("image/png")); + setPreparedForLayerIds(layerIds); + }, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]); + + return [downloadImageUrl, prepareDownload]; +} + +export default OutfitControls; diff --git a/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js b/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js new file mode 100644 index 00000000..8d54bddb --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js @@ -0,0 +1,299 @@ +import React from "react"; +import { Box, VStack } from "@chakra-ui/react"; +import { WarningTwoIcon } from "@chakra-ui/icons"; +import { FaBug } from "react-icons/fa"; +import { GlitchBadgeLayout, layerUsesHTML5 } from "../components/HTML5Badge"; +import getVisibleLayers from "../components/getVisibleLayers"; +import { useLocalStorage } from "../util"; + +function OutfitKnownGlitchesBadge({ appearance }) { + const [hiResMode] = useLocalStorage("DTIHiResMode", false); + const { petAppearance, items } = appearance; + + const glitchMessages = []; + + // Look for UC/Invisible/etc incompatibilities that we hid, that we should + // just mark Incompatible someday instead; or with correctly partially-hidden + // art. + // + // NOTE: This particular glitch is checking for the *absence* of layers, so + // we skip it if we're still loading! + if (!appearance.loading) { + for (const item of items) { + // HACK: We use `getVisibleLayers` with just this pet appearance and item + // appearance, to run the logic for which layers are compatible with + // this pet. But `getVisibleLayers` does other things too, so it's + // plausible that this could do not quite what we want in some cases! + const allItemLayers = item.appearance.layers; + const compatibleItemLayers = getVisibleLayers(petAppearance, [ + item.appearance, + ]).filter((l) => l.source === "item"); + + if (compatibleItemLayers.length === 0) { + glitchMessages.push( + + {item.name} isn't actually compatible with this special pet. + We're hiding the item art, which is outdated behavior, and we should + instead be treating it as entirely incompatible. Fixing this is in + our todo list, sorry for the confusing UI! + + ); + } else if (compatibleItemLayers.length < allItemLayers.length) { + glitchMessages.push( + + {item.name}'s compatibility with this pet is complicated, but + we believe this is how it looks: some zones are visible, and some + zones are hidden. If this isn't quite right, please email me at + matchu@openneo.net and let me know! + + ); + } + } + } + + // Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch. + for (const item of items) { + const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) => + (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") + ); + const itemHasBrokenUnconvertedLayers = item.appearance.layers.some( + (l) => + (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") && + !layerUsesHTML5(l) + ); + if (itemHasBrokenOnNeopetsDotCom) { + glitchMessages.push( + + {itemHasBrokenUnconvertedLayers ? ( + <> + We're aware of a glitch affecting the art for {item.name}. + Last time we checked, this glitch affected its appearance on + Neopets.com, too. Hopefully this will be fixed once it's converted + to HTML5! + + ) : ( + <> + We're aware of a previous glitch affecting the art for{" "} + {item.name}, but it might have been resolved during HTML5 + conversion. Please use the feedback form on the homepage to let us + know if it looks right, or still looks wrong! Thank you! + + )} + + ); + } + } + + // Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch. + for (const item of items) { + const itemHasGlitch = item.appearance.layers.some((l) => + (l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT") + ); + if (itemHasGlitch) { + glitchMessages.push( + + There's a glitch in the art for {item.name}, and we believe it + looks this way on-site, too. But our version might be out of date! If + you've seen it look better on-site, please email me at + matchu@openneo.net so we can fix it! + + ); + } + } + + // Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this + // if hi-res mode is on, because otherwise it doesn't affect the user anyway! + if (hiResMode) { + for (const item of items) { + const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) => + (l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT") + ); + if (itemHasOfficialSvgIsIncorrect) { + glitchMessages.push( + + There's a glitch in the art for {item.name} that prevents us + from showing the SVG image for Hi-Res Mode. Instead, we're showing a + PNG, which might look a bit blurry on larger screens. + + ); + } + } + } + + // Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch. + for (const item of items) { + const itemHasGlitch = item.appearance.layers.some((l) => + (l.knownGlitches || []).includes("DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN") + ); + if (itemHasGlitch) { + glitchMessages.push( + + There's a glitch in the art for {item.name} that causes it to + display incorrectly—but we're not sure if it's on our end, or TNT's. + If you own this item, please email me at matchu@openneo.net to let us + know how it looks in the on-site customizer! + + ); + } + } + + // Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch. + for (const item of items) { + const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) => + (l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT") + ); + if (itemHasOfficialBodyIdIsIncorrect) { + glitchMessages.push( + + Last we checked, {item.name} actually is compatible with this + pet, even though it seems like it shouldn't be. But TNT might change + this at any time, so be careful! + + ); + } + } + + // Look for Dyeworks items that aren't converted yet. + for (const item of items) { + const itemIsDyeworks = item.name.includes("Dyeworks"); + const itemIsConverted = item.appearance.layers.every(layerUsesHTML5); + + if (itemIsDyeworks && !itemIsConverted) { + glitchMessages.push( + + {item.name} isn't converted to HTML5 yet, and our Classic DTI + code often shows old Dyeworks items in the wrong color. Once it's + converted, we'll display it correctly! + + ); + } + } + + // Check whether the pet is Invisible. If so, we'll show a blanket warning. + if (petAppearance?.color?.id === "38") { + glitchMessages.push( + + Invisible pets are affected by a number of glitches, including faces + sometimes being visible on-site, and errors in the HTML5 conversion. If + this pose looks incorrect, you can try another by clicking the emoji + face next to the species/color picker. But be aware that Neopets.com + might look different! + + ); + } + + // Check if this is a Faerie Uni. If so, we'll explain the dithering horns. + if ( + petAppearance?.color?.id === "26" && + petAppearance?.species?.id === "49" + ) { + glitchMessages.push( + + The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and + sometimes yellow. To help you design for both cases, we show the blue + horn with the feminine design, and the yellow horn with the masculine + design—but the pet's gender does not actually affect which horn you'll + get, and it will often change over time! + + ); + } + + // Check whether the pet appearance is marked as Glitched. + if (petAppearance?.isGlitched) { + glitchMessages.push( + // NOTE: This message assumes that the current pet appearance is the + // best canonical one, but it's _possible_ to view Glitched + // appearances even if we _do_ have a better one saved... but + // only the Support UI ever takes you there. + + We know that the art for this pet is incorrect, but we still haven't + seen a correct model for this pose yet. Once someone models the + correct data, we'll use that instead. For now, you could also try + switching to another pose, by clicking the emoji face next to the + species/color picker! + + ); + } + + const petLayers = petAppearance?.layers || []; + + // Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch. + for (const layer of petLayers) { + const layerHasGlitch = (layer.knownGlitches || []).includes( + "OFFICIAL_SWF_IS_INCORRECT" + ); + if (layerHasGlitch) { + glitchMessages.push( + + We're aware of a glitch affecting the art for this pet's{" "} + {layer.zone.label} zone. Last time we checked, this glitch + affected its appearance on Neopets.com, too. But our version might be + out of date! If you've seen it look better on-site, please email me at + matchu@openneo.net so we can fix it! + + ); + } + } + + // Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch. + if (hiResMode) { + for (const layer of petLayers) { + const layerHasOfficialSvgIsIncorrect = ( + layer.knownGlitches || [] + ).includes("OFFICIAL_SVG_IS_INCORRECT"); + if (layerHasOfficialSvgIsIncorrect) { + glitchMessages.push( + + There's a glitch in the art for this pet's {layer.zone.label}{" "} + zone that prevents us from showing the SVG image for Hi-Res Mode. + Instead, we're showing a PNG, which might look a bit blurry on + larger screens. + + ); + } + } + } + + // Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch. + for (const layer of petLayers) { + const layerHasGlitch = (layer.knownGlitches || []).includes( + "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN" + ); + if (layerHasGlitch) { + glitchMessages.push( + + There's a glitch in the art for this pet's {layer.zone.label}{" "} + zone that causes it to display incorrectly—but we're not sure if it's + on our end, or TNT's. If you have this pet, please email me at + matchu@openneo.net to let us know how it looks in the on-site + customizer! + + ); + } + } + + if (glitchMessages.length === 0) { + return null; + } + + return ( + + + Known glitches + + {glitchMessages} + + } + > + + + + ); +} + +export default OutfitKnownGlitchesBadge; diff --git a/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js b/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js new file mode 100644 index 00000000..fcca31e3 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js @@ -0,0 +1,743 @@ +import React from "react"; +import gql from "graphql-tag"; +import { useQuery } from "@apollo/client"; +import { ClassNames } from "@emotion/react"; +import { + Box, + Button, + Flex, + Popover, + PopoverArrow, + PopoverContent, + PopoverTrigger, + Portal, + VisuallyHidden, + useColorModeValue, + useTheme, + useToast, +} from "@chakra-ui/react"; +import { loadable } from "../util"; +import Image from "next/image"; + +import { petAppearanceFragment } from "../components/useOutfitAppearance"; +import getVisibleLayers from "../components/getVisibleLayers"; +import { OutfitLayers } from "../components/OutfitPreview"; +import SupportOnly from "./support/SupportOnly"; +import useSupport from "./support/useSupport"; +import { useLocalStorage } from "../util"; + +// From https://twemoji.twitter.com/, thank you! +import twemojiSmile from "../images/twemoji/smile.svg"; +import twemojiCry from "../images/twemoji/cry.svg"; +import twemojiSick from "../images/twemoji/sick.svg"; +import twemojiSunglasses from "../images/twemoji/sunglasses.svg"; +import twemojiQuestion from "../images/twemoji/question.svg"; +import twemojiMasc from "../images/twemoji/masc.svg"; +import twemojiFem from "../images/twemoji/fem.svg"; + +const PosePickerSupport = loadable(() => import("./support/PosePickerSupport")); + +const PosePickerSupportSwitch = loadable(() => + import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch) +); + +/** + * PosePicker shows the pet poses available on the current species/color, and + * lets the user choose which want they want! + * + * NOTE: This component is memoized with React.memo. It's relatively expensive + * to re-render on every outfit change - the contents update even if the + * popover is closed! This makes wearing/unwearing items noticeably + * slower on lower-power devices. + * + * So, instead of using `outfitState` like most components, we specify + * exactly which props we need, so that `React.memo` can see the changes + * that matter, and skip updates that don't. + */ +function PosePicker({ + speciesId, + colorId, + pose, + appearanceId, + dispatchToOutfit, + onLockFocus, + onUnlockFocus, + ...props +}) { + const theme = useTheme(); + const initialFocusRef = React.useRef(); + const { loading, error, poseInfos } = usePoses(speciesId, colorId, pose); + const [isInSupportMode, setIsInSupportMode] = useLocalStorage( + "DTIPosePickerIsInSupportMode", + false + ); + const { isSupportUser } = useSupport(); + const toast = useToast(); + + // Resize the Popover when we toggle support mode, because it probably will + // affect the content size. + React.useLayoutEffect(() => { + // HACK: To trigger a Popover resize, we simulate a window resize event, + // because Popover listens for window resizes to reposition itself. + // I've also filed an issue requesting an official API! + // https://github.com/chakra-ui/chakra-ui/issues/1853 + window.dispatchEvent(new Event("resize")); + }, [isInSupportMode]); + + // Generally, the app tries to never put us in an invalid pose state. But it + // can happen with direct URL navigation, or pet loading when modeling isn't + // updated! Let's do some recovery. + const selectedPoseIsAvailable = Object.values(poseInfos).some( + (pi) => pi.isSelected && pi.isAvailable + ); + const firstAvailablePose = Object.values(poseInfos).find( + (pi) => pi.isAvailable + )?.pose; + React.useEffect(() => { + if (loading) { + return; + } + + if (!selectedPoseIsAvailable) { + if (!firstAvailablePose) { + // TODO: I suppose this error would fit better in SpeciesColorPicker! + toast({ + status: "error", + title: "Oops, we don't have data for this pet color!", + description: + "If it's new, this might be a modeling issue—try modeling it on " + + "Classic DTI first. Sorry!", + duration: null, + isClosable: true, + }); + return; + } + + console.warn( + `Pose ${pose} not found for speciesId=${speciesId}, ` + + `colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.` + ); + dispatchToOutfit({ type: "setPose", pose: firstAvailablePose }); + } + }, [ + loading, + selectedPoseIsAvailable, + firstAvailablePose, + speciesId, + colorId, + pose, + toast, + dispatchToOutfit, + ]); + + if (loading) { + return null; + } + + // This is a low-stakes enough control, where enough pairs don't have data + // anyway, that I think I want to just not draw attention to failures. + if (error) { + return null; + } + + // If there's only one pose anyway, don't bother showing a picker! + // (Unless we're Support, in which case we want the ability to pop it open to + // inspect and label the Unknown poses!) + const numAvailablePoses = Object.values(poseInfos).filter( + (p) => p.isAvailable + ).length; + if (numAvailablePoses <= 1 && !isSupportUser) { + return null; + } + + const onChange = (e) => { + dispatchToOutfit({ type: "setPose", pose: e.target.value }); + }; + + return ( + + {({ isOpen }) => ( + + {({ css, cx }) => ( + <> + + + + + + + {isInSupportMode ? ( + + ) : ( + <> + + {numAvailablePoses <= 1 && ( + + + The empty picker is hidden for most users! +
+ You can see it because you're a Support user. +
+
+ )} + + )} + + + setIsInSupportMode(e.target.checked)} + /> + + +
+ +
+
+ + )} +
+ )} +
+ ); +} + +function PosePickerTable({ poseInfos, onChange, initialFocusRef }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ {poseInfos.unconverted.isAvailable && ( + + )} +
+ ); +} + +function Cell({ children, as }) { + const Tag = as; + return ( + + + {children} + + + ); +} + +const EMOTION_STRINGS = { + HAPPY_MASC: "Happy", + HAPPY_FEM: "Happy", + SAD_MASC: "Sad", + SAD_FEM: "Sad", + SICK_MASC: "Sick", + SICK_FEM: "Sick", +}; + +const GENDER_PRESENTATION_STRINGS = { + HAPPY_MASC: "Masculine", + SAD_MASC: "Masculine", + SICK_MASC: "Masculine", + HAPPY_FEM: "Feminine", + SAD_FEM: "Feminine", + SICK_FEM: "Feminine", +}; + +function PoseOption({ + poseInfo, + onChange, + inputRef, + size = "md", + label, + ...otherProps +}) { + const theme = useTheme(); + const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose]; + const emotionStr = EMOTION_STRINGS[poseInfo.pose]; + + let poseName = + poseInfo.pose === "UNCONVERTED" + ? "Unconverted" + : `${emotionStr} and ${genderPresentationStr}`; + if (!poseInfo.isAvailable) { + poseName += ` (not modeled yet)`; + } + + const borderColor = useColorModeValue( + theme.colors.green["600"], + theme.colors.green["300"] + ); + + return ( + + {({ css, cx }) => ( + { + // HACK: We need the timeout to beat the popover's focus stealing! + const input = e.currentTarget.querySelector("input"); + setTimeout(() => input.focus(), 0); + }} + {...otherProps} + > + + + + {poseInfo.isAvailable ? ( + + + + ) : ( + + + + )} + + {label && ( + + {label} + + )} + + )} + + ); +} + +function EmojiImage({ src, alt, boxSize = 16 }) { + return ( + {alt} + ); +} + +function usePoses(speciesId, colorId, selectedPose) { + const { loading, error, data } = useQuery( + gql` + query PosePicker($speciesId: ID!, $colorId: ID!) { + happyMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: HAPPY_MASC + ) { + ...PetAppearanceForPosePicker + } + sadMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SAD_MASC + ) { + ...PetAppearanceForPosePicker + } + sickMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SICK_MASC + ) { + ...PetAppearanceForPosePicker + } + happyFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: HAPPY_FEM + ) { + ...PetAppearanceForPosePicker + } + sadFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SAD_FEM + ) { + ...PetAppearanceForPosePicker + } + sickFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SICK_FEM + ) { + ...PetAppearanceForPosePicker + } + unconverted: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: UNCONVERTED + ) { + ...PetAppearanceForPosePicker + } + unknown: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: UNKNOWN + ) { + ...PetAppearanceForPosePicker + } + } + + ${petAppearanceForPosePickerFragment} + `, + { variables: { speciesId, colorId }, onError: (e) => console.error(e) } + ); + + const poseInfos = { + happyMasc: { + ...data?.happyMasc, + pose: "HAPPY_MASC", + isAvailable: Boolean(data?.happyMasc), + isSelected: selectedPose === "HAPPY_MASC", + }, + sadMasc: { + ...data?.sadMasc, + pose: "SAD_MASC", + isAvailable: Boolean(data?.sadMasc), + isSelected: selectedPose === "SAD_MASC", + }, + sickMasc: { + ...data?.sickMasc, + pose: "SICK_MASC", + isAvailable: Boolean(data?.sickMasc), + isSelected: selectedPose === "SICK_MASC", + }, + happyFem: { + ...data?.happyFem, + pose: "HAPPY_FEM", + isAvailable: Boolean(data?.happyFem), + isSelected: selectedPose === "HAPPY_FEM", + }, + sadFem: { + ...data?.sadFem, + pose: "SAD_FEM", + isAvailable: Boolean(data?.sadFem), + isSelected: selectedPose === "SAD_FEM", + }, + sickFem: { + ...data?.sickFem, + pose: "SICK_FEM", + isAvailable: Boolean(data?.sickFem), + isSelected: selectedPose === "SICK_FEM", + }, + unconverted: { + ...data?.unconverted, + pose: "UNCONVERTED", + isAvailable: Boolean(data?.unconverted), + isSelected: selectedPose === "UNCONVERTED", + }, + unknown: { + ...data?.unknown, + pose: "UNKNOWN", + isAvailable: Boolean(data?.unknown), + isSelected: selectedPose === "UNKNOWN", + }, + }; + + return { loading, error, poseInfos }; +} + +function getIcon(pose) { + if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) { + return twemojiSmile; + } else if (["SAD_MASC", "SAD_FEM"].includes(pose)) { + return twemojiCry; + } else if (["SICK_MASC", "SICK_FEM"].includes(pose)) { + return twemojiSick; + } else if (pose === "UNCONVERTED") { + return twemojiSunglasses; + } else { + return twemojiQuestion; + } +} + +function getTransform(poseInfo) { + const { pose, bodyId } = poseInfo; + if (pose === "UNCONVERTED") { + return transformsByBodyId.default; + } + if (bodyId in transformsByBodyId) { + return transformsByBodyId[bodyId]; + } + return transformsByBodyId.default; +} + +export const petAppearanceForPosePickerFragment = gql` + fragment PetAppearanceForPosePicker on PetAppearance { + id + bodyId + pose + ...PetAppearanceForOutfitPreview + } + ${petAppearanceFragment} +`; + +const transformsByBodyId = { + 93: "translate(-5px, 10px) scale(2.8)", + 106: "translate(-8px, 8px) scale(2.9)", + 47: "translate(-1px, 17px) scale(3)", + 84: "translate(-21px, 22px) scale(3.2)", + 146: "translate(2px, 15px) scale(3.3)", + 250: "translate(-14px, 28px) scale(3.4)", + 212: "translate(-4px, 8px) scale(2.9)", + 74: "translate(-26px, 30px) scale(3.0)", + 94: "translate(-4px, 8px) scale(3.1)", + 132: "translate(-14px, 18px) scale(3.0)", + 56: "translate(-7px, 24px) scale(2.9)", + 90: "translate(-16px, 20px) scale(3.5)", + 136: "translate(-11px, 18px) scale(3.0)", + 138: "translate(-14px, 26px) scale(3.5)", + 166: "translate(-13px, 24px) scale(3.1)", + 119: "translate(-6px, 29px) scale(3.1)", + 126: "translate(3px, 13px) scale(3.1)", + 67: "translate(2px, 27px) scale(3.4)", + 163: "translate(-7px, 16px) scale(3.1)", + 147: "translate(-2px, 15px) scale(3.0)", + 80: "translate(-2px, -17px) scale(3.0)", + 117: "translate(-14px, 16px) scale(3.6)", + 201: "translate(-16px, 16px) scale(3.2)", + 51: "translate(-2px, 6px) scale(3.2)", + 208: "translate(-3px, 6px) scale(3.7)", + 196: "translate(-7px, 19px) scale(5.2)", + 143: "translate(-16px, 20px) scale(3.5)", + 150: "translate(-3px, 24px) scale(3.2)", + 175: "translate(-9px, 15px) scale(3.4)", + 173: "translate(3px, 57px) scale(4.4)", + 199: "translate(-28px, 35px) scale(3.8)", + 52: "translate(-8px, 33px) scale(3.5)", + 109: "translate(-8px, -6px) scale(3.2)", + 134: "translate(-14px, 14px) scale(3.1)", + 95: "translate(-12px, 0px) scale(3.4)", + 96: "translate(6px, 23px) scale(3.3)", + 154: "translate(-20px, 25px) scale(3.6)", + 55: "translate(-16px, 28px) scale(4.0)", + 76: "translate(-8px, 11px) scale(3.0)", + 156: "translate(2px, 12px) scale(3.5)", + 78: "translate(-3px, 18px) scale(3.0)", + 191: "translate(-18px, 46px) scale(4.4)", + 187: "translate(-6px, 22px) scale(3.2)", + 46: "translate(-2px, 19px) scale(3.4)", + 178: "translate(-11px, 32px) scale(3.3)", + 100: "translate(-13px, 23px) scale(3.3)", + 130: "translate(-14px, 4px) scale(3.1)", + 188: "translate(-9px, 24px) scale(3.5)", + 257: "translate(-14px, 25px) scale(3.4)", + 206: "translate(-7px, 4px) scale(3.6)", + 101: "translate(-13px, 16px) scale(3.2)", + 68: "translate(-2px, 13px) scale(3.2)", + 182: "translate(-6px, 4px) scale(3.1)", + 180: "translate(-15px, 22px) scale(3.6)", + 306: "translate(1px, 14px) scale(3.1)", + default: "scale(2.5)", +}; + +export default React.memo(PosePicker); diff --git a/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js b/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js new file mode 100644 index 00000000..8c5bbd8e --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js @@ -0,0 +1,80 @@ +import React from "react"; +import * as Sentry from "@sentry/react"; +import { Box, Flex } from "@chakra-ui/react"; +import SearchToolbar from "./SearchToolbar"; +import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util"; +import PaginationToolbar from "../components/PaginationToolbar"; +import { useSearchResults } from "./useSearchResults"; + +/** + * SearchFooter appears on large screens only, to let you search for new items + * while still keeping the rest of the item screen open! + */ +function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) { + const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage( + "DTIFeatureFlagCanUseSearchFooter", + false + ); + + const { items, numTotalPages } = useSearchResults( + searchQuery, + outfitState, + 1 + ); + + React.useEffect(() => { + if (window.location.search.includes("feature-flag-can-use-search-footer")) { + setCanUseSearchFooter(true); + } + }, [setCanUseSearchFooter]); + + // TODO: Show the new footer to other users, too! + if (!canUseSearchFooter) { + return null; + } + + return ( + + + + + + + Add new items: + + + + + {numTotalPages != null && ( + + alert("TODO")} + buildPageUrl={() => null} + size="sm" + /> + + )} + + + + + {items.map((item) => ( + + {item.name} + + ))} + + + + + ); +} + +export default SearchFooter; diff --git a/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js b/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js new file mode 100644 index 00000000..e70dfc2c --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js @@ -0,0 +1,284 @@ +import React from "react"; +import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react"; + +import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; +import PaginationToolbar from "../components/PaginationToolbar"; +import { useSearchResults } from "./useSearchResults"; + +export const SEARCH_PER_PAGE = 30; + +/** + * SearchPanel shows item search results to the user, so they can preview them + * and add them to their outfit! + * + * It's tightly coordinated with SearchToolbar, using refs to control special + * keyboard and focus interactions. + */ +function SearchPanel({ + query, + outfitState, + dispatchToOutfit, + scrollContainerRef, + searchQueryRef, + firstSearchResultRef, +}) { + const scrollToTop = React.useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [scrollContainerRef]); + + // Sometimes we want to give focus back to the search field! + const onMoveFocusUpToQuery = (e) => { + if (searchQueryRef.current) { + searchQueryRef.current.focus(); + e.preventDefault(); + } + }; + + return ( + { + // This will catch any Escape presses when the user's focus is inside + // the SearchPanel. + if (e.key === "Escape") { + onMoveFocusUpToQuery(e); + } + }} + > + + + ); +} + +/** + * SearchResults loads the search results from the user's query, renders them, + * and tracks the scroll container for infinite scrolling. + * + * For each item, we render a
+ } + > + + + Bulk-add: + + + + setMinAssetId(e.target.value || null)} + /> + + + + + setMinAssetId( + e.target.value + ? Number(e.target.value) - assetIdStepValue * (numSpecies - 1) + : null + ) + } + /> + + + + for + + + + + + + + ); +} + +const allAppearancesFragment = gql` + fragment AllAppearancesForItem on Item { + allAppearances { + id + body { + id + representsAllBodies + canonicalAppearance { + id + species { + id + name + } + color { + id + name + isStandard + } + pose + ...PetAppearanceForOutfitPreview + } + } + ...ItemAppearanceForOutfitPreview + } + } + + ${itemAppearanceFragment} + ${petAppearanceFragment} +`; + +function AllItemLayersSupportModalContent({ + item, + bulkAddProposal, + onBulkAddComplete, +}) { + const { supportSecret } = useSupport(); + + const { loading, error, data } = useQuery( + gql` + query AllItemLayersSupportModal($itemId: ID!) { + item(id: $itemId) { + id + ...AllAppearancesForItem + } + } + + ${allAppearancesFragment} + `, + { variables: { itemId: item.id } } + ); + + const { + loading: loading2, + error: error2, + data: bulkAddProposalData, + } = useQuery( + gql` + query AllItemLayersSupportModal_BulkAddProposal( + $layerRemoteIds: [ID!]! + $colorId: ID! + ) { + layersToAdd: itemAppearanceLayersByRemoteId( + remoteIds: $layerRemoteIds + ) { + id + remoteId + ...AppearanceLayerForOutfitPreview + ...AppearanceLayerForSupport + } + + color(id: $colorId) { + id + appliedToAllCompatibleSpecies { + id + species { + id + name + } + body { + id + } + canonicalAppearance { + # These are a bit redundant, but it's convenient to just reuse + # what the other query is already doing. + id + species { + id + name + } + color { + id + name + isStandard + } + pose + ...PetAppearanceForOutfitPreview + } + } + } + } + + ${appearanceLayerFragment} + ${appearanceLayerFragmentForSupport} + ${petAppearanceFragment} + `, + { + variables: { + layerRemoteIds: bulkAddProposal + ? Array.from({ length: 54 }, (_, i) => + String( + Number(bulkAddProposal.minAssetId) + + i * bulkAddProposal.assetIdStepValue + ) + ) + : [], + colorId: bulkAddProposal?.colorId, + }, + skip: bulkAddProposal == null, + } + ); + + const [ + sendBulkAddMutation, + { loading: mutationLoading, error: mutationError }, + ] = useMutation(gql` + mutation AllItemLayersSupportModal_BulkAddMutation( + $itemId: ID! + $entries: [BulkAddLayersToItemEntry!]! + $supportSecret: String! + ) { + bulkAddLayersToItem( + itemId: $itemId + entries: $entries + supportSecret: $supportSecret + ) { + id + ...AllAppearancesForItem + } + } + + ${allAppearancesFragment} + `); + + if (loading || loading2) { + return ( + + + + ); + } + + if (error || error2) { + return {(error || error2).message}; + } + + let itemAppearances = data.item?.allAppearances || []; + itemAppearances = mergeBulkAddProposalIntoItemAppearances( + itemAppearances, + bulkAddProposal, + bulkAddProposalData + ); + itemAppearances = [...itemAppearances].sort((a, b) => { + const aKey = getSortKeyForBody(a.body); + const bKey = getSortKeyForBody(b.body); + return aKey.localeCompare(bKey); + }); + + return ( + + {bulkAddProposalData && ( + + Previewing bulk-add changes + + {mutationError && ( + + {mutationError.message} + + )} + + + + + )} + + {itemAppearances.map((itemAppearance) => ( + + + + ))} + + + ); +} + +function ItemAppearanceCard({ item, itemAppearance }) { + const petAppearance = itemAppearance.body.canonicalAppearance; + const biologyLayers = petAppearance.layers; + const itemLayers = [...itemAppearance.layers].sort( + (a, b) => a.zone.depth - b.zone.depth + ); + + const { brightBackground } = useCommonStyles(); + + return ( + + + {getBodyName(itemAppearance.body)} + + + + {itemLayers.length === 0 && ( + + + (No data) + + + )} + {itemLayers.map((itemLayer) => ( + + + + ))} + + + ); +} + +function getSortKeyForBody(body) { + // "All bodies" sorts first! + if (body.representsAllBodies) { + return ""; + } + + const { color, species } = body.canonicalAppearance; + // Sort standard colors first, then special colors by name, then by species + // within each color. + return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`; +} + +function getBodyName(body) { + if (body.representsAllBodies) { + return "All bodies"; + } + + const { species, color } = body.canonicalAppearance; + const speciesName = capitalize(species.name); + const colorName = color.isStandard ? "Standard" : capitalize(color.name); + return `${colorName} ${speciesName}`; +} + +function capitalize(str) { + return str[0].toUpperCase() + str.slice(1); +} + +function mergeBulkAddProposalIntoItemAppearances( + itemAppearances, + bulkAddProposal, + bulkAddProposalData +) { + if (!bulkAddProposalData) { + return itemAppearances; + } + + const { color, layersToAdd } = bulkAddProposalData; + + // Do a deep copy of the existing item appearances, so we can mutate them as + // we loop through them in this function! + const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances)); + + // To exclude Vandagyre, we take the first N species by ID - which is + // different than the alphabetical sort order we use for assigning layers! + const speciesColorPairsToInclude = [...color.appliedToAllCompatibleSpecies] + .sort((a, b) => Number(a.species.id) - Number(b.species.id)) + .slice(0, bulkAddProposal.numSpecies); + + // Set up the incoming data in convenient formats. + const sortedSpeciesColorPairs = [...speciesColorPairsToInclude].sort((a, b) => + a.species.name.localeCompare(b.species.name) + ); + const layersToAddByRemoteId = {}; + for (const layer of layersToAdd) { + layersToAddByRemoteId[layer.remoteId] = layer; + } + + for (const [index, speciesColorPair] of sortedSpeciesColorPairs.entries()) { + const { body, canonicalAppearance } = speciesColorPair; + + // Find the existing item appearance to add to, or create a new one if it + // doesn't exist yet. + let itemAppearance = mergedItemAppearances.find( + (a) => a.body.id === body.id && !a.body.representsAllBodies + ); + if (!itemAppearance) { + itemAppearance = { + id: `bulk-add-proposal-new-item-appearance-for-body-${body.id}`, + layers: [], + body: { + id: body.id, + canonicalAppearance, + }, + }; + mergedItemAppearances.push(itemAppearance); + } + + const layerToAddRemoteId = String( + Number(bulkAddProposal.minAssetId) + + index * bulkAddProposal.assetIdStepValue + ); + const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId]; + if (!layerToAdd) { + continue; + } + + // Delete this layer from other appearances (because we're going to + // override its body ID), then add it to this new one. + for (const otherItemAppearance of mergedItemAppearances) { + const indexToDelete = otherItemAppearance.layers.findIndex( + (l) => l.remoteId === layerToAddRemoteId + ); + if (indexToDelete >= 0) { + otherItemAppearance.layers.splice(indexToDelete, 1); + } + } + itemAppearance.layers.push(layerToAdd); + } + + return mergedItemAppearances; +} + +export default AllItemLayersSupportModal; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js new file mode 100644 index 00000000..ea0618f5 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js @@ -0,0 +1,644 @@ +import * as React from "react"; +import gql from "graphql-tag"; +import { useMutation } from "@apollo/client"; +import { + Button, + Box, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Radio, + RadioGroup, + Spinner, + useDisclosure, + useToast, + CheckboxGroup, + VStack, + Checkbox, +} from "@chakra-ui/react"; +import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons"; + +import AppearanceLayerSupportUploadModal from "./AppearanceLayerSupportUploadModal"; +import Metadata, { MetadataLabel, MetadataValue } from "./Metadata"; +import { OutfitLayers } from "../../components/OutfitPreview"; +import SpeciesColorPicker from "../../components/SpeciesColorPicker"; +import useOutfitAppearance, { + itemAppearanceFragment, +} from "../../components/useOutfitAppearance"; +import useSupport from "./useSupport"; + +/** + * AppearanceLayerSupportModal offers Support info and tools for a specific item + * appearance layer. Open it by clicking a layer from ItemSupportDrawer. + */ +function AppearanceLayerSupportModal({ + item, // Specify this or `petAppearance` + petAppearance, // Specify this or `item` + layer, + outfitState, // speciesId, colorId, pose + isOpen, + onClose, +}) { + const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId); + const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState( + layer.knownGlitches + ); + + const [previewBiology, setPreviewBiology] = React.useState({ + speciesId: outfitState.speciesId, + colorId: outfitState.colorId, + pose: outfitState.pose, + isValid: true, + }); + const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false); + const { supportSecret } = useSupport(); + const toast = useToast(); + + const parentName = item + ? item.name + : `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`; + + const [mutate, { loading: mutationLoading, error: mutationError }] = + useMutation( + gql` + mutation ApperanceLayerSupportSetLayerBodyId( + $layerId: ID! + $bodyId: ID! + $knownGlitches: [AppearanceLayerKnownGlitch!]! + $supportSecret: String! + $outfitSpeciesId: ID! + $outfitColorId: ID! + $formPreviewSpeciesId: ID! + $formPreviewColorId: ID! + ) { + setLayerBodyId( + layerId: $layerId + bodyId: $bodyId + supportSecret: $supportSecret + ) { + # This mutation returns the affected AppearanceLayer. Fetch the + # updated fields, including the appearance on the outfit pet and the + # form preview pet, to automatically update our cached appearance in + # the rest of the app. That means you should be able to see your + # changes immediately! + id + bodyId + item { + id + appearanceOnOutfit: appearanceOn( + speciesId: $outfitSpeciesId + colorId: $outfitColorId + ) { + ...ItemAppearanceForOutfitPreview + } + + appearanceOnFormPreviewPet: appearanceOn( + speciesId: $formPreviewSpeciesId + colorId: $formPreviewColorId + ) { + ...ItemAppearanceForOutfitPreview + } + } + } + + setLayerKnownGlitches( + layerId: $layerId + knownGlitches: $knownGlitches + supportSecret: $supportSecret + ) { + id + knownGlitches + svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT + } + } + ${itemAppearanceFragment} + `, + { + variables: { + layerId: layer.id, + bodyId: selectedBodyId, + knownGlitches: selectedKnownGlitches, + supportSecret, + outfitSpeciesId: outfitState.speciesId, + outfitColorId: outfitState.colorId, + formPreviewSpeciesId: previewBiology.speciesId, + formPreviewColorId: previewBiology.colorId, + }, + onCompleted: () => { + onClose(); + toast({ + status: "success", + title: `Saved layer ${layer.id}: ${parentName}`, + }); + }, + } + ); + + // TODO: Would be nicer to just learn the correct URL from the server, but we + // don't happen to be saving it, and it would be extra stuff to put on + // the GraphQL request for non-Support users. We could also just try + // loading them, but, ehhh… + const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls( + layer.swfUrl + ); + + return ( + + + + + Layer {layer.id}: {parentName} + + + + + DTI ID: + {layer.id} + Neopets ID: + {layer.remoteId} + Zone: + + {layer.zone.label} ({layer.zone.id}) + + Assets: + + + + + + + {layer.canvasMovieLibraryUrl ? ( + + ) : ( + + )} + {layer.svgUrl ? ( + + ) : ( + + )} + {layer.imageUrl ? ( + + ) : ( + + )} + + + {item && ( + <> + + setUploadModalIsOpen(false)} + /> + + )} + + + + + {item && ( + <> + + + + )} + + + + {item && ( + + )} + + {mutationError && ( + + {mutationError.message} + + )} + + + + + + ); +} + +function AppearanceLayerSupportPetCompatibilityFields({ + item, + layer, + outfitState, + selectedBodyId, + previewBiology, + onChangeBodyId, + onChangePreviewBiology, +}) { + const [selectedBiology, setSelectedBiology] = React.useState(previewBiology); + + const { + loading, + error, + visibleLayers, + bodyId: appearanceBodyId, + } = useOutfitAppearance({ + speciesId: previewBiology.speciesId, + colorId: previewBiology.colorId, + pose: previewBiology.pose, + wornItemIds: [item.id], + }); + + const biologyLayers = visibleLayers.filter((l) => l.source === "pet"); + + // After we touch a species/color selector and null out `bodyId`, when the + // appearance body ID loads in, select it as the new body ID. + // + // This might move the radio button away from "all pets", but I think that's + // a _less_ surprising experience: if you're touching the pickers, then + // that's probably where you head is. + React.useEffect(() => { + if (selectedBodyId == null && appearanceBodyId != null) { + onChangeBodyId(appearanceBodyId); + } + }, [selectedBodyId, appearanceBodyId, onChangeBodyId]); + + return ( + + Pet compatibility + onChangeBodyId(newBodyId)} + marginBottom="4" + > + + Fits all pets{" "} + + (Body ID: 0) + + + + Fits all pets with the same body as:{" "} + + (Body ID:{" "} + {appearanceBodyId == null ? ( + + ) : ( + appearanceBodyId + )} + ) + + + + + + + + { + const speciesId = species.id; + const colorId = color.id; + + setSelectedBiology({ speciesId, colorId, isValid, pose }); + if (isValid) { + onChangePreviewBiology({ speciesId, colorId, isValid, pose }); + + // Also temporarily null out the body ID. We'll switch to the new + // body ID once it's loaded. + onChangeBodyId(null); + } + }} + /> + + {!error && ( + + If it doesn't look right, try some other options until it does! + + )} + {error && {error.message}} + + + ); +} + +function AppearanceLayerSupportKnownGlitchesFields({ + selectedKnownGlitches, + onChange, +}) { + return ( + + Known glitches + + + + Official SWF is incorrect{" "} + + (Will display a message) + + + + Official SVG is incorrect{" "} + + (Will use the PNG instead) + + + + Official Movie is incorrect{" "} + + (Will display a message) + + + + Displays incorrectly, but cause unknown{" "} + + (Will display a vague message) + + + + Fits all pets on-site, but should not{" "} + + (TNT's fault. Will show a message, and keep the compatibility + settings above.) + + + + Only fits pets with other body-specific assets{" "} + + (DTI's fault: bodyId=0 is a lie! Will mark incompatible for some + pets.) + + + + + + ); +} + +function AppearanceLayerSupportModalRemoveButton({ + item, + layer, + outfitState, + onRemoveSuccess, +}) { + const { isOpen, onOpen, onClose } = useDisclosure(); + const toast = useToast(); + const { supportSecret } = useSupport(); + + const [mutate, { loading, error }] = useMutation( + gql` + mutation AppearanceLayerSupportRemoveButton( + $layerId: ID! + $itemId: ID! + $outfitSpeciesId: ID! + $outfitColorId: ID! + $supportSecret: String! + ) { + removeLayerFromItem( + layerId: $layerId + itemId: $itemId + supportSecret: $supportSecret + ) { + # This mutation returns the affected layer, and the affected item. + # Fetch the updated appearance for the current outfit, which should + # no longer include this layer. This means you should be able to see + # your changes immediately! + item { + id + appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) { + ...ItemAppearanceForOutfitPreview + } + } + + # The layer's item should be null now, fetch to confirm and update! + layer { + id + item { + id + } + } + } + } + ${itemAppearanceFragment} + `, + { + variables: { + layerId: layer.id, + itemId: item.id, + outfitSpeciesId: outfitState.speciesId, + outfitColorId: outfitState.colorId, + supportSecret, + }, + onCompleted: () => { + onClose(); + onRemoveSuccess(); + toast({ + status: "success", + title: `Removed layer ${layer.id} from ${item.name}`, + }); + }, + } + ); + + return ( + <> + + + + + + + Remove Layer {layer.id} ({layer.zone.label}) from {item.name}? + + + + This will permanently-ish remove Layer {layer.id} ( + {layer.zone.label}) from this item. + + + If you remove a correct layer by mistake, re-modeling should fix + it, or Matchu can restore it if you write down the layer ID + before proceeding! + + + Are you sure you want to remove Layer {layer.id} from this item? + + + + + + {error && ( + + {error.message} + + )} + + + + + + + ); +} + +const SWF_URL_PATTERN = + /^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/; + +function convertSwfUrlToPossibleManifestUrls(swfUrl) { + const match = new URL(swfUrl, "http://images.neopets.com") + .toString() + .match(SWF_URL_PATTERN); + if (!match) { + throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`); + } + + const type = match[1]; + const folders = match[2]; + const hash = match[3]; + + // TODO: There are a few potential manifest URLs in play! Long-term, we + // should get this from modeling data. But these are some good guesses! + return [ + `http://images.neopets.com/cp/${type}/data/${folders}/manifest.json`, + `http://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`, + ]; +} + +export default AppearanceLayerSupportModal; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js new file mode 100644 index 00000000..8e730562 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js @@ -0,0 +1,639 @@ +import * as React from "react"; +import { useApolloClient } from "@apollo/client"; +import { + Button, + Box, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Select, + useToast, +} from "@chakra-ui/react"; +import { ExternalLinkIcon } from "@chakra-ui/icons"; + +import { safeImageUrl } from "../../util"; +import useSupport from "./useSupport"; + +/** + * AppearanceLayerSupportUploadModal helps Support users create and upload PNGs for + * broken appearance layers. Useful when the auto-converters are struggling, + * e.g. the SWF uses a color filter our server-side Flash player can't support! + */ +function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) { + const [step, setStep] = React.useState(1); + const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null); + const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null); + + const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null); + const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null); + const [numWarnings, setNumWarnings] = React.useState(null); + + const [isUploading, setIsUploading] = React.useState(false); + const [uploadError, setUploadError] = React.useState(null); + + const [conflictMode, setConflictMode] = React.useState("onBlack"); + + const { supportSecret } = useSupport(); + const toast = useToast(); + const apolloClient = useApolloClient(); + + // Once both images are ready, merge them! + React.useEffect(() => { + if (!imageOnBlackUrl || !imageOnWhiteUrl) { + return; + } + + setImageWithAlphaUrl(null); + setNumWarnings(null); + setIsUploading(false); + + mergeIntoImageWithAlpha( + imageOnBlackUrl, + imageOnWhiteUrl, + conflictMode + ).then(([url, blob, numWarnings]) => { + setImageWithAlphaUrl(url); + setImageWithAlphaBlob(blob); + setNumWarnings(numWarnings); + }); + }, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]); + + const onUpload = React.useCallback( + (e) => { + const file = e.target.files[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (re) => { + switch (step) { + case 1: + setImageOnBlackUrl(re.target.result); + setStep(2); + return; + case 2: + setImageOnWhiteUrl(re.target.result); + setStep(3); + return; + default: + throw new Error(`unexpected step ${step}`); + } + }; + reader.readAsDataURL(file); + }, + [step] + ); + + const onSubmitFinalImage = React.useCallback(async () => { + setIsUploading(true); + setUploadError(null); + try { + const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, { + method: "POST", + headers: { + "DTI-Support-Secret": supportSecret, + }, + body: imageWithAlphaBlob, + }); + + if (!res.ok) { + setIsUploading(false); + setUploadError( + new Error(`Network error: ${res.status} ${res.statusText}`) + ); + return; + } + + setIsUploading(false); + onClose(); + toast({ + status: "success", + title: "Image successfully uploaded", + description: "It might take a few seconds to update in the app!", + }); + + // NOTE: I tried to do this as a cache update, but I couldn't ever get + // the fragment with size parameters to work :/ (Other fields would + // update, but not these!) Ultimately the eviction is the only + // reliable method I found :/ + apolloClient.cache.evict({ + id: `AppearanceLayer:${layer.id}`, + fieldName: "imageUrl", + }); + apolloClient.cache.evict({ + id: `AppearanceLayer:${layer.id}`, + fieldName: "imageUrlV2", + }); + } catch (e) { + setIsUploading(false); + setUploadError(e); + } + }, [ + imageWithAlphaBlob, + supportSecret, + layer.id, + toast, + onClose, + apolloClient.cache, + ]); + + return ( + + + + + Upload PNG for {item.name} + + + + {(step === 1 || step === 2) && ( + + )} + {step === 3 && ( + + )} + + + + + {uploadError && ( + + {uploadError.message} + + )} + + {step === 3 && ( + + )} + + + + + ); +} + +function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) { + return ( + <> + + Step {step}: Take a screenshot of exactly the 600×600 Flash + region, then upload it below. +
+ The border will turn green once the entire region is in view. +
+ + + + + + + + + ); +} + +function AppearanceLayerSupportReviewStep({ + imageWithAlphaUrl, + numWarnings, + conflictMode, + onChangeConflictMode, +}) { + if (imageWithAlphaUrl == null) { + return Generating image…; + } + + const ratioBad = numWarnings / (600 * 600); + const ratioGood = 1 - ratioBad; + + return ( + <> + + Step 3: Does this look correct? If so, let's upload it! + + + ({Math.floor(ratioGood * 10000) / 100}% match,{" "} + {Math.floor(ratioBad * 10000) / 100}% mismatch.) + + + {imageWithAlphaUrl && ( + // eslint-disable-next-line @next/next/no-img-element + Generated layer PNG, on a checkered background + )} + + + {numWarnings > 0 && ( + + + When pixels conflict, we use… + + + + )} + + + ); +} + +function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) { + const [isVisible, setIsVisible] = React.useState(null); + const regionRef = React.useRef(null); + + // We detect whether the entire SWF region is visible, because Flash only + // bothers to render in visible places. So, screenshotting a SWF container + // that isn't fully visible will fill the not-visible space with black, + // instead of the actual SWF content. We change the border color to hint this + // to the user! + React.useLayoutEffect(() => { + const region = regionRef.current; + if (!region) { + return; + } + + const scrollParent = region.closest(".chakra-modal__overlay"); + if (!scrollParent) { + throw new Error(`could not find .chakra-modal__overlay scroll parent`); + } + + const onMountOrScrollOrResize = () => { + const regionBox = region.getBoundingClientRect(); + const scrollParentBox = scrollParent.getBoundingClientRect(); + const isVisible = + regionBox.left > scrollParentBox.left && + regionBox.right < scrollParentBox.right && + regionBox.top > scrollParentBox.top && + regionBox.bottom < scrollParentBox.bottom; + setIsVisible(isVisible); + }; + + onMountOrScrollOrResize(); + + scrollParent.addEventListener("scroll", onMountOrScrollOrResize); + window.addEventListener("resize", onMountOrScrollOrResize); + + return () => { + scrollParent.removeEventListener("scroll", onMountOrScrollOrResize); + window.removeEventListener("resize", onMountOrScrollOrResize); + }; + }, []); + + let borderColor; + if (isVisible === null) { + borderColor = "gray.400"; + } else if (isVisible === false) { + borderColor = "red.400"; + } else if (isVisible === true) { + borderColor = "green.400"; + } + + return ( + + + + + + + + + + ); +} + +async function mergeIntoImageWithAlpha( + imageOnBlackUrl, + imageOnWhiteUrl, + conflictMode +) { + const [imageOnBlack, imageOnWhite] = await Promise.all([ + readImageDataFromUrl(imageOnBlackUrl), + readImageDataFromUrl(imageOnWhiteUrl), + ]); + + const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha( + imageOnBlack, + imageOnWhite, + conflictMode + ); + const [ + imageWithAlphaUrl, + imageWithAlphaBlob, + ] = await writeImageDataToUrlAndBlob(imageWithAlphaData); + + return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings]; +} + +function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) { + const imageWithAlpha = new ImageData(600, 600); + let numWarnings = 0; + + for (let x = 0; x < 600; x++) { + for (let y = 0; y < 600; y++) { + const pixelIndex = (600 * y + x) << 2; + + const rOnBlack = imageOnBlack.data[pixelIndex]; + const gOnBlack = imageOnBlack.data[pixelIndex + 1]; + const bOnBlack = imageOnBlack.data[pixelIndex + 2]; + const rOnWhite = imageOnWhite.data[pixelIndex]; + const gOnWhite = imageOnWhite.data[pixelIndex + 1]; + const bOnWhite = imageOnWhite.data[pixelIndex + 2]; + if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) { + if (numWarnings < 100) { + console.warn( + `[${x}x${y}] color on white should be lighter than color on ` + + `black, see pixel ${x}x${y}: ` + + `#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` + + `${gOnWhite.toString(16)}` + + ` vs ` + + `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` + + `${gOnWhite.toString(16)}. ` + + `Using conflict mode ${conflictMode} to fall back.` + ); + } + + const [r, g, b, a] = resolveConflict( + [rOnBlack, gOnBlack, bOnBlack], + [rOnWhite, gOnWhite, bOnWhite], + conflictMode + ); + imageWithAlpha.data[pixelIndex] = r; + imageWithAlpha.data[pixelIndex + 1] = g; + imageWithAlpha.data[pixelIndex + 2] = b; + imageWithAlpha.data[pixelIndex + 3] = a; + + numWarnings++; + continue; + } + + // The true alpha is how close together the on-white and on-black colors + // are. If they're totally the same, it's 255 opacity. If they're totally + // different, it's 0 opacity. In between, it scales linearly with the + // difference! + const alpha = 255 - (rOnWhite - rOnBlack); + + // Check that the alpha derived from other channels makes sense too. + const alphaByB = 255 - (bOnWhite - bOnBlack); + const alphaByG = 255 - (gOnWhite - gOnBlack); + const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG); + const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG)); + if (highestAlpha - lowestAlpha > 2) { + if (numWarnings < 100) { + console.warn( + `[${x}x${y}] derived alpha values don't match: ` + + `${alpha} vs ${alphaByB} vs ${alphaByG}. ` + + `Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` + + `${gOnWhite.toString(16)}` + + ` vs ` + + `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` + + `${gOnWhite.toString(16)}. ` + + `Using conflict mode ${conflictMode} to fall back.` + ); + } + + const [r, g, b, a] = resolveConflict( + [rOnBlack, gOnBlack, bOnBlack], + [rOnWhite, gOnWhite, bOnWhite], + conflictMode + ); + imageWithAlpha.data[pixelIndex] = r; + imageWithAlpha.data[pixelIndex + 1] = g; + imageWithAlpha.data[pixelIndex + 2] = b; + imageWithAlpha.data[pixelIndex + 3] = a; + + numWarnings++; + continue; + } + + // And the true color is the color on black, divided by the true alpha. + // We can derive this from the definition of the color on black, which is + // simply the true color times the true alpha. Divide to undo! + const alphaRatio = alpha / 255; + const rOnAlpha = Math.round(rOnBlack / alphaRatio); + const gOnAlpha = Math.round(gOnBlack / alphaRatio); + const bOnAlpha = Math.round(bOnBlack / alphaRatio); + + imageWithAlpha.data[pixelIndex] = rOnAlpha; + imageWithAlpha.data[pixelIndex + 1] = gOnAlpha; + imageWithAlpha.data[pixelIndex + 2] = bOnAlpha; + imageWithAlpha.data[pixelIndex + 3] = alpha; + } + } + + return [imageWithAlpha, numWarnings]; +} + +/** + * readImageDataFromUrl reads an image URL to ImageData, by drawing it on a + * canvas and reading ImageData back from it. + */ +async function readImageDataFromUrl(url) { + const image = new Image(); + + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + image.src = url; + }); + + const canvas = document.createElement("canvas"); + canvas.width = 600; + canvas.height = 600; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, 600, 600); + return ctx.getImageData(0, 0, 600, 600); +} + +/** + * writeImageDataToUrl writes an ImageData to a data URL and Blob, by drawing + * it on a canvas and reading the URL and Blob back from it. + */ +async function writeImageDataToUrlAndBlob(imageData) { + const canvas = document.createElement("canvas"); + canvas.width = 600; + canvas.height = 600; + + const ctx = canvas.getContext("2d"); + ctx.putImageData(imageData, 0, 0); + + const dataUrl = canvas.toDataURL("image/png"); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png") + ); + return [dataUrl, blob]; +} + +function resolveConflict( + [rOnBlack, gOnBlack, bOnBlack], + [rOnWhite, gOnWhite, bOnWhite], + conflictMode +) { + if (conflictMode === "onBlack") { + return [rOnBlack, gOnBlack, bOnBlack, 255]; + } else if (conflictMode === "onWhite") { + return [rOnWhite, gOnWhite, bOnWhite, 255]; + } else if (conflictMode === "transparent") { + return [0, 0, 0, 0]; + } else if (conflictMode === "moreColorful") { + const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack); + const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite); + if (sOnBlack > sOnWhite) { + return [rOnBlack, gOnBlack, bOnBlack, 255]; + } else { + return [rOnWhite, gOnWhite, bOnWhite, 255]; + } + } else { + throw new Error(`unexpected conflict mode ${conflictMode}`); + } +} + +/** + * Returns the given color's saturation, as a ratio from 0 to 1. + * Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/ + */ +function computeSaturation(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const cmin = Math.min(r, g, b); + const cmax = Math.max(r, g, b); + const delta = cmax - cmin; + + const l = (cmax + cmin) / 2; + const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + + return s; +} + +export default AppearanceLayerSupportUploadModal; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js new file mode 100644 index 00000000..9812223b --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js @@ -0,0 +1,99 @@ +import * as React from "react"; +import { ClassNames } from "@emotion/react"; +import { Box, useColorModeValue, useDisclosure } from "@chakra-ui/react"; +import { EditIcon } from "@chakra-ui/icons"; +import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal"; +import { OutfitLayers } from "../../components/OutfitPreview"; + +function ItemSupportAppearanceLayer({ + item, + itemLayer, + biologyLayers, + outfitState, +}) { + const { isOpen, onOpen, onClose } = useDisclosure(); + + const iconButtonBgColor = useColorModeValue("green.100", "green.300"); + const iconButtonColor = useColorModeValue("green.800", "gray.900"); + + return ( + + {({ css }) => ( + + + + + + + + + + {itemLayer.zone.label} + {" "} + + (Zone {itemLayer.zone.id}) + + + Neopets ID: {itemLayer.remoteId} + DTI ID: {itemLayer.id} + + + )} + + ); +} + +export default ItemSupportAppearanceLayer; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js new file mode 100644 index 00000000..dbd586c4 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js @@ -0,0 +1,463 @@ +import * as React from "react"; +import gql from "graphql-tag"; +import { useQuery, useMutation } from "@apollo/client"; +import { + Badge, + Box, + Button, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerHeader, + DrawerOverlay, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + HStack, + Link, + Select, + Spinner, + Stack, + Text, + useBreakpointValue, + useColorModeValue, + useDisclosure, +} from "@chakra-ui/react"; +import { + CheckCircleIcon, + ChevronRightIcon, + ExternalLinkIcon, +} from "@chakra-ui/icons"; + +import AllItemLayersSupportModal from "./AllItemLayersSupportModal"; +import Metadata, { MetadataLabel, MetadataValue } from "./Metadata"; +import useOutfitAppearance from "../../components/useOutfitAppearance"; +import { OutfitStateContext } from "../useOutfitState"; +import useSupport from "./useSupport"; +import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer"; + +/** + * ItemSupportDrawer shows Support UI for the item when open. + * + * This component controls the drawer element. The actual content is imported + * from another lazy-loaded component! + */ +function ItemSupportDrawer({ item, isOpen, onClose }) { + const placement = useBreakpointValue({ base: "bottom", lg: "right" }); + + return ( + + + + + + {item.name} + + Support + + + + + Item ID: + {item.id} + Restricted zones: + + + + + + + + + + + + + ); +} + +function ItemSupportRestrictedZones({ item }) { + const { speciesId, colorId } = React.useContext(OutfitStateContext); + + // NOTE: It would be a better reflection of the data to just query restricted + // zones right off the item... but we already have them in cache from + // the appearance, so query them that way to be instant in practice! + const { loading, error, data } = useQuery( + gql` + query ItemSupportRestrictedZones( + $itemId: ID! + $speciesId: ID! + $colorId: ID! + ) { + item(id: $itemId) { + id + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + restrictedZones { + id + label + } + } + } + } + `, + { variables: { itemId: item.id, speciesId, colorId } } + ); + + if (loading) { + return ; + } + + if (error) { + return {error.message}; + } + + const restrictedZones = data?.item?.appearanceOn?.restrictedZones || []; + if (restrictedZones.length === 0) { + return "None"; + } + + return restrictedZones + .map((z) => `${z.label} (${z.id})`) + .sort() + .join(", "); +} + +function ItemSupportFields({ item }) { + const { loading, error, data } = useQuery( + gql` + query ItemSupportFields($itemId: ID!) { + item(id: $itemId) { + id + manualSpecialColor { + id + } + explicitlyBodySpecific + } + } + `, + { + variables: { itemId: item.id }, + + // HACK: I think it's a bug in @apollo/client 3.1.1 that, if the + // optimistic response sets `manualSpecialColor` to null, the query + // doesn't update, even though its cache has updated :/ + // + // This cheap trick of changing the display name every re-render + // persuades Apollo that this is a different query, so it re-checks + // its cache and finds the empty `manualSpecialColor`. Weird! + displayName: `ItemSupportFields-${new Date()}`, + } + ); + + const errorColor = useColorModeValue("red.500", "red.300"); + + return ( + <> + {error && {error.message}} + + + + ); +} + +function ItemSupportSpecialColorFields({ + loading, + error, + item, + manualSpecialColor, +}) { + const { supportSecret } = useSupport(); + + const { + loading: colorsLoading, + error: colorsError, + data: colorsData, + } = useQuery( + gql` + query ItemSupportDrawerAllColors { + allColors { + id + name + isStandard + } + } + ` + ); + + const [ + mutate, + { loading: mutationLoading, error: mutationError, data: mutationData }, + ] = useMutation(gql` + mutation ItemSupportDrawerSetManualSpecialColor( + $itemId: ID! + $colorId: ID + $supportSecret: String! + ) { + setManualSpecialColor( + itemId: $itemId + colorId: $colorId + supportSecret: $supportSecret + ) { + id + manualSpecialColor { + id + } + } + } + `); + + const onChange = React.useCallback( + (e) => { + const colorId = e.target.value || null; + const color = + colorId != null ? { __typename: "Color", id: colorId } : null; + mutate({ + variables: { + itemId: item.id, + colorId, + supportSecret, + }, + optimisticResponse: { + __typename: "Mutation", + setManualSpecialColor: { + __typename: "Item", + id: item.id, + manualSpecialColor: color, + }, + }, + }).catch((e) => { + // Ignore errors from the promise, because we'll handle them on render! + }); + }, + [item.id, mutate, supportSecret] + ); + + const nonStandardColors = + colorsData?.allColors?.filter((c) => !c.isStandard) || []; + nonStandardColors.sort((a, b) => a.name.localeCompare(b.name)); + + const linkColor = useColorModeValue("green.500", "green.300"); + + return ( + + Special color + + {colorsError && ( + {colorsError.message} + )} + {mutationError && ( + {mutationError.message} + )} + {!colorsError && !mutationError && ( + + This controls which previews we show on the{" "} + + classic item page + + . + + )} + + ); +} + +function ItemSupportPetCompatibilityRuleFields({ + loading, + error, + item, + explicitlyBodySpecific, +}) { + const { supportSecret } = useSupport(); + + const [ + mutate, + { loading: mutationLoading, error: mutationError, data: mutationData }, + ] = useMutation(gql` + mutation ItemSupportDrawerSetItemExplicitlyBodySpecific( + $itemId: ID! + $explicitlyBodySpecific: Boolean! + $supportSecret: String! + ) { + setItemExplicitlyBodySpecific( + itemId: $itemId + explicitlyBodySpecific: $explicitlyBodySpecific + supportSecret: $supportSecret + ) { + id + explicitlyBodySpecific + } + } + `); + + const onChange = React.useCallback( + (e) => { + const explicitlyBodySpecific = e.target.value === "true"; + mutate({ + variables: { + itemId: item.id, + explicitlyBodySpecific, + supportSecret, + }, + optimisticResponse: { + __typename: "Mutation", + setItemExplicitlyBodySpecific: { + __typename: "Item", + id: item.id, + explicitlyBodySpecific, + }, + }, + }).catch((e) => { + // Ignore errors from the promise, because we'll handle them on render! + }); + }, + [item.id, mutate, supportSecret] + ); + + return ( + + Pet compatibility rule + + {mutationError && ( + {mutationError.message} + )} + {!mutationError && ( + + By default, we assume Background-y zones fit all pets the same. When + items don't follow that rule, we can override it. + + )} + + ); +} + +/** + * NOTE: This component takes `outfitState` from context, rather than as a prop + * from its parent, for performance reasons. We want `Item` to memoize + * and generally skip re-rendering on `outfitState` changes, and to make + * sure the context isn't accessed when the drawer is closed. So we use + * it here, only when the drawer is open! + */ +function ItemSupportAppearanceLayers({ item }) { + const outfitState = React.useContext(OutfitStateContext); + const { speciesId, colorId, pose, appearanceId } = outfitState; + const { error, visibleLayers } = useOutfitAppearance({ + speciesId, + colorId, + pose, + appearanceId, + wornItemIds: [item.id], + }); + + const biologyLayers = visibleLayers.filter((l) => l.source === "pet"); + const itemLayers = visibleLayers.filter((l) => l.source === "item"); + itemLayers.sort((a, b) => a.zone.depth - b.zone.depth); + + const modalState = useDisclosure(); + + return ( + + + Appearance layers + + + + + + {itemLayers.map((itemLayer) => ( + + ))} + + {error && {error.message}} + + ); +} + +export default ItemSupportDrawer; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js b/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js new file mode 100644 index 00000000..30262948 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js @@ -0,0 +1,40 @@ +import * as React from "react"; +import { Box } from "@chakra-ui/react"; + +/** + * Metadata is a UI component for showing metadata about something, as labels + * and their values. + */ +function Metadata({ children, ...props }) { + return ( + + {children} + + ); +} + +function MetadataLabel({ children, ...props }) { + return ( + + {children} + + ); +} + +function MetadataValue({ children, ...props }) { + return ( + + {children} + + ); +} + +export default Metadata; +export { MetadataLabel, MetadataValue }; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js b/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js new file mode 100644 index 00000000..8302057e --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js @@ -0,0 +1,582 @@ +import React from "react"; +import gql from "graphql-tag"; +import { useMutation, useQuery } from "@apollo/client"; +import { + Box, + Button, + IconButton, + Select, + Spinner, + Switch, + Wrap, + WrapItem, + useDisclosure, + UnorderedList, + ListItem, +} from "@chakra-ui/react"; +import { + ArrowBackIcon, + ArrowForwardIcon, + CheckCircleIcon, + EditIcon, +} from "@chakra-ui/icons"; + +import HangerSpinner from "../../components/HangerSpinner"; +import Metadata, { MetadataLabel, MetadataValue } from "./Metadata"; +import useSupport from "./useSupport"; +import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal"; +import { petAppearanceForPosePickerFragment } from "../PosePicker"; + +function PosePickerSupport({ + speciesId, + colorId, + pose, + appearanceId, + initialFocusRef, + dispatchToOutfit, +}) { + const { loading, error, data } = useQuery( + gql` + query PosePickerSupport($speciesId: ID!, $colorId: ID!) { + petAppearances(speciesId: $speciesId, colorId: $colorId) { + id + pose + isGlitched + layers { + id + zone { + id + label @client + } + + # For AppearanceLayerSupportModal + remoteId + bodyId + swfUrl + svgUrl + imageUrl: imageUrlV2(idealSize: SIZE_600) + canvasMovieLibraryUrl + } + restrictedZones { + id + label @client + } + + # For AppearanceLayerSupportModal to know the name + species { + id + name + } + color { + id + name + } + + # Also, anything the PosePicker wants that isn't here, so that we + # don't have to refetch anything when we change the canonical poses. + ...PetAppearanceForPosePicker + } + + ...CanonicalPetAppearances + } + ${canonicalPetAppearancesFragment} + ${petAppearanceForPosePickerFragment} + `, + { variables: { speciesId, colorId } } + ); + + // Resize the Popover when we toggle loading state, because it probably will + // affect the content size. appearanceId might also affect content size, if + // it occupies different zones. + // + // NOTE: This also triggers an additional necessary resize when the component + // first mounts, because PosePicker lazy-loads it, so it actually + // mounting affects size too. + React.useLayoutEffect(() => { + // HACK: To trigger a Popover resize, we simulate a window resize event, + // because Popover listens for window resizes to reposition itself. + // I've also filed an issue requesting an official API! + // https://github.com/chakra-ui/chakra-ui/issues/1853 + window.dispatchEvent(new Event("resize")); + }, [loading, appearanceId]); + + const canonicalAppearanceIdsByPose = { + HAPPY_MASC: data?.happyMasc?.id, + SAD_MASC: data?.sadMasc?.id, + SICK_MASC: data?.sickMasc?.id, + HAPPY_FEM: data?.happyFem?.id, + SAD_FEM: data?.sadFem?.id, + SICK_FEM: data?.sickFem?.id, + UNCONVERTED: data?.unconverted?.id, + UNKNOWN: data?.unknown?.id, + }; + const canonicalAppearanceIds = Object.values( + canonicalAppearanceIdsByPose + ).filter((id) => id); + + const providedAppearanceId = appearanceId; + if (!providedAppearanceId) { + appearanceId = canonicalAppearanceIdsByPose[pose]; + } + + // If you don't already have `appearanceId` in the outfit state, opening up + // PosePickerSupport adds it! That way, if you make changes that affect the + // canonical poses, we'll still stay navigated to this one. + React.useEffect(() => { + if (!providedAppearanceId && appearanceId) { + dispatchToOutfit({ + type: "setPose", + pose, + appearanceId, + }); + } + }, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error.message} + + ); + } + + const currentPetAppearance = data.petAppearances.find( + (pa) => pa.id === appearanceId + ); + if (!currentPetAppearance) { + return ( + + Pet appearance with ID {JSON.stringify(appearanceId)} not found + + ); + } + + return ( + + + + DTI ID: + {appearanceId} + Pose: + + + + Layers: + + + {currentPetAppearance.layers + .map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer]) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([text, layer]) => ( + + + {text} + + + + ))} + + + Restricts: + + {currentPetAppearance.restrictedZones.length > 0 ? ( + + {currentPetAppearance.restrictedZones + .map((zone) => `${zone.label} (${zone.id})`) + .sort((a, b) => a[0].localeCompare(b[0])) + .map((zoneText) => ( + {zoneText} + ))} + + ) : ( + + None + + )} + + + + ); +} + +function PetLayerSupportLink({ outfitState, petAppearance, layer, children }) { + const { isOpen, onOpen, onClose } = useDisclosure(); + return ( + <> + + + + ); +} + +function PosePickerSupportNavigator({ + petAppearances, + currentPetAppearance, + canonicalAppearanceIds, + dropdownRef, + dispatchToOutfit, +}) { + const currentIndex = petAppearances.indexOf(currentPetAppearance); + const prevPetAppearance = petAppearances[currentIndex - 1]; + const nextPetAppearance = petAppearances[currentIndex + 1]; + + return ( + + } + size="sm" + marginRight="2" + isDisabled={prevPetAppearance == null} + onClick={() => + dispatchToOutfit({ + type: "setPose", + pose: prevPetAppearance.pose, + appearanceId: prevPetAppearance.id, + }) + } + /> + + } + size="sm" + marginLeft="2" + isDisabled={nextPetAppearance == null} + onClick={() => + dispatchToOutfit({ + type: "setPose", + pose: nextPetAppearance.pose, + appearanceId: nextPetAppearance.id, + }) + } + /> + + ); +} + +function PosePickerSupportPoseFields({ petAppearance, speciesId, colorId }) { + const { supportSecret } = useSupport(); + + const [mutatePose, poseMutation] = useMutation( + gql` + mutation PosePickerSupportSetPetAppearancePose( + $appearanceId: ID! + $pose: Pose! + $supportSecret: String! + ) { + setPetAppearancePose( + appearanceId: $appearanceId + pose: $pose + supportSecret: $supportSecret + ) { + id + pose + } + } + `, + { + refetchQueries: [ + { + query: gql` + query PosePickerSupportRefetchCanonicalAppearances( + $speciesId: ID! + $colorId: ID! + ) { + ...CanonicalPetAppearances + } + ${canonicalPetAppearancesFragment} + `, + variables: { speciesId, colorId }, + }, + ], + } + ); + + const [mutateIsGlitched, isGlitchedMutation] = useMutation( + gql` + mutation PosePickerSupportSetPetAppearanceIsGlitched( + $appearanceId: ID! + $isGlitched: Boolean! + $supportSecret: String! + ) { + setPetAppearanceIsGlitched( + appearanceId: $appearanceId + isGlitched: $isGlitched + supportSecret: $supportSecret + ) { + id + isGlitched + } + } + `, + { + refetchQueries: [ + { + query: gql` + query PosePickerSupportRefetchCanonicalAppearances( + $speciesId: ID! + $colorId: ID! + ) { + ...CanonicalPetAppearances + } + ${canonicalPetAppearancesFragment} + `, + variables: { speciesId, colorId }, + }, + ], + } + ); + + return ( + + + + + + {poseMutation.error && ( + {poseMutation.error.message} + )} + {isGlitchedMutation.error && ( + {isGlitchedMutation.error.message} + )} + + ); +} + +export function PosePickerSupportSwitch({ isChecked, onChange }) { + return ( + + + + 💖 + + + + + ); +} + +const POSE_NAMES = { + HAPPY_MASC: "Happy Masc", + HAPPY_FEM: "Happy Fem", + SAD_MASC: "Sad Masc", + SAD_FEM: "Sad Fem", + SICK_MASC: "Sick Masc", + SICK_FEM: "Sick Fem", + UNCONVERTED: "Unconverted", + UNKNOWN: "Unknown", +}; + +const canonicalPetAppearancesFragment = gql` + fragment CanonicalPetAppearances on Query { + happyMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: HAPPY_MASC + ) { + id + } + + sadMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SAD_MASC + ) { + id + } + + sickMasc: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SICK_MASC + ) { + id + } + + happyFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: HAPPY_FEM + ) { + id + } + + sadFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SAD_FEM + ) { + id + } + + sickFem: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: SICK_FEM + ) { + id + } + + unconverted: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: UNCONVERTED + ) { + id + } + + unknown: petAppearance( + speciesId: $speciesId + colorId: $colorId + pose: UNKNOWN + ) { + id + } + } +`; + +export default PosePickerSupport; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js b/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js new file mode 100644 index 00000000..a1702bfc --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js @@ -0,0 +1,19 @@ +import useSupport from "./useSupport"; + +/** + * SupportOnly only shows its contents to Support users. For most users, the + * content will be hidden! + * + * To become a Support user, you visit /?supportSecret=..., which saves the + * secret to your device. + * + * Note that this component doesn't check that the secret is *correct*, so it's + * possible to view this UI by faking an invalid secret. That's okay, because + * the server checks the provided secret for each Support request. + */ +function SupportOnly({ children }) { + const { isSupportUser } = useSupport(); + return isSupportUser ? children : null; +} + +export default SupportOnly; diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js b/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js new file mode 100644 index 00000000..af3c8afe --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js @@ -0,0 +1,32 @@ +import * as React from "react"; + +/** + * useSupport returns the Support secret that the server requires for Support + * actions... if the user has it set. For most users, this returns nothing! + * + * Specifically, we return an object of: + * - isSupportUser: true iff the `supportSecret` is set + * - supportSecret: the secret saved to this device, or null if not set + * + * To become a Support user, you visit /?supportSecret=..., which saves the + * secret to your device. + * + * Note that this hook doesn't check that the secret is *correct*, so it's + * possible that it will return an invalid secret. That's okay, because + * the server checks the provided secret for each Support request. + */ +function useSupport() { + const supportSecret = React.useMemo( + () => + typeof localStorage !== "undefined" + ? localStorage.getItem("supportSecret") + : null, + [] + ); + + const isSupportUser = supportSecret != null; + + return { isSupportUser, supportSecret }; +} + +export default useSupport; diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js new file mode 100644 index 00000000..6e044097 --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js @@ -0,0 +1,249 @@ +import React from "react"; +import { useToast } from "@chakra-ui/react"; +import { useRouter } from "next/router"; +import { useDebounce } from "../util"; +import useCurrentUser from "../components/useCurrentUser"; +import gql from "graphql-tag"; +import { useMutation } from "@apollo/client"; +import { outfitStatesAreEqual } from "./useOutfitState"; + +function useOutfitSaving(outfitState, dispatchToOutfit) { + const { isLoggedIn, id: currentUserId } = useCurrentUser(); + const { pathname, push: pushHistory } = useRouter(); + const toast = useToast(); + + // There's not a way to reset an Apollo mutation state to clear out the error + // when the outfit changes… so we track the error state ourselves! + const [saveError, setSaveError] = React.useState(null); + + // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved + // to the server. + const isNewOutfit = outfitState.id == null; + + // Whether this outfit's latest local changes have been saved to the server. + // And log it to the console! + const latestVersionIsSaved = + outfitState.savedOutfitState && + outfitStatesAreEqual( + outfitState.outfitStateWithoutExtras, + outfitState.savedOutfitState + ); + React.useEffect(() => { + console.debug( + "[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o", + latestVersionIsSaved, + outfitState.outfitStateWithoutExtras, + outfitState.savedOutfitState + ); + }, [ + latestVersionIsSaved, + outfitState.outfitStateWithoutExtras, + outfitState.savedOutfitState, + ]); + + // Only logged-in users can save outfits - and they can only save new outfits, + // or outfits they created. + const canSaveOutfit = + isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId); + + // Users can delete their own outfits too. The logic is slightly different + // than for saving, because you can save an outfit that hasn't been saved + // yet, but you can't delete it. + const canDeleteOutfit = !isNewOutfit && canSaveOutfit; + + const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation( + gql` + mutation UseOutfitSaving_SaveOutfit( + $id: ID # Optional, is null when saving new outfits. + $name: String # Optional, server may fill in a placeholder. + $speciesId: ID! + $colorId: ID! + $pose: Pose! + $wornItemIds: [ID!]! + $closetedItemIds: [ID!]! + ) { + outfit: saveOutfit( + id: $id + name: $name + speciesId: $speciesId + colorId: $colorId + pose: $pose + wornItemIds: $wornItemIds + closetedItemIds: $closetedItemIds + ) { + id + name + petAppearance { + id + species { + id + } + color { + id + } + pose + } + wornItems { + id + } + closetedItems { + id + } + creator { + id + } + } + } + `, + { + context: { sendAuth: true }, + update: (cache, { data: { outfit } }) => { + // After save, add this outfit to the current user's outfit list. This + // will help when navigating back to Your Outfits, to force a refresh. + // https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation + cache.modify({ + id: cache.identify(outfit.creator), + fields: { + outfits: (existingOutfitRefs = [], { readField }) => { + const isAlreadyInList = existingOutfitRefs.some( + (ref) => readField("id", ref) === outfit.id + ); + if (isAlreadyInList) { + return existingOutfitRefs; + } + + const newOutfitRef = cache.writeFragment({ + data: outfit, + fragment: gql` + fragment NewOutfit on Outfit { + id + } + `, + }); + + return [...existingOutfitRefs, newOutfitRef]; + }, + }, + }); + + // Also, send a `rename` action, if this is still the current outfit, + // and the server renamed it (e.g. "Untitled outfit (1)"). (It's + // tempting to do a full reset, in case the server knows something we + // don't, but we don't want to clobber changes the user made since + // starting the save!) + if (outfit.id === outfitState.id && outfit.name !== outfitState.name) { + dispatchToOutfit({ + type: "rename", + outfitName: outfit.name, + }); + } + }, + } + ); + + const saveOutfitFromProvidedState = React.useCallback( + (outfitState) => { + sendSaveOutfitMutation({ + variables: { + id: outfitState.id, + name: outfitState.name, + speciesId: outfitState.speciesId, + colorId: outfitState.colorId, + pose: outfitState.pose, + wornItemIds: [...outfitState.wornItemIds], + closetedItemIds: [...outfitState.closetedItemIds], + }, + }) + .then(({ data: { outfit } }) => { + // Navigate to the new saved outfit URL. Our Apollo cache should pick + // up the data from this mutation response, and combine it with the + // existing cached data, to make this smooth without any loading UI. + if (pathname !== `/outfits/[outfitId]`) { + pushHistory(`/outfits/${outfit.id}`); + } + }) + .catch((e) => { + console.error(e); + setSaveError(e); + toast({ + status: "error", + title: "Sorry, there was an error saving this outfit!", + description: "Maybe check your connection and try again.", + }); + }); + }, + // It's important that this callback _doesn't_ change when the outfit + // changes, so that the auto-save effect is only responding to the + // debounced state! + [sendSaveOutfitMutation, pathname, pushHistory, toast] + ); + + const saveOutfit = React.useCallback( + () => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras), + [saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras] + ); + + // Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`, + // which only contains the basic fields, and will keep a stable object + // identity until actual changes occur. Then, save the outfit after the user + // has left it alone for long enough, so long as it's actually different + // than the saved state. + const debouncedOutfitState = useDebounce( + outfitState.outfitStateWithoutExtras, + 2000, + { + // When the outfit ID changes, update the debounced state immediately! + forceReset: (debouncedOutfitState, newOutfitState) => + debouncedOutfitState.id !== newOutfitState.id, + } + ); + // HACK: This prevents us from auto-saving the outfit state that's still + // loading. I worry that this might not catch other loading scenarios + // though, like if the species/color/pose is in the GQL cache, but the + // items are still loading in... not sure where this would happen tho! + const debouncedOutfitStateIsSaveable = + debouncedOutfitState.speciesId && + debouncedOutfitState.colorId && + debouncedOutfitState.pose; + React.useEffect(() => { + if ( + !isNewOutfit && + canSaveOutfit && + debouncedOutfitStateIsSaveable && + !outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState) + ) { + console.info( + "[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o", + outfitState.savedOutfitState, + debouncedOutfitState + ); + saveOutfitFromProvidedState(debouncedOutfitState); + } + }, [ + isNewOutfit, + canSaveOutfit, + debouncedOutfitState, + debouncedOutfitStateIsSaveable, + outfitState.savedOutfitState, + saveOutfitFromProvidedState, + ]); + + // When the outfit changes, clear out the error state from previous saves. + // We'll send the mutation again after the debounce, and we don't want to + // show the error UI in the meantime! + React.useEffect(() => { + setSaveError(null); + }, [outfitState.outfitStateWithoutExtras]); + + return { + canSaveOutfit, + canDeleteOutfit, + isNewOutfit, + isSaving, + latestVersionIsSaved, + saveError, + saveOutfit, + }; +} + +export default useOutfitSaving; diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js new file mode 100644 index 00000000..c137763a --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js @@ -0,0 +1,708 @@ +import React from "react"; +import gql from "graphql-tag"; +import produce, { enableMapSet } from "immer"; +import { useQuery, useApolloClient } from "@apollo/client"; + +import { itemAppearanceFragment } from "../components/useOutfitAppearance"; +import { useRouter } from "next/router"; + +enableMapSet(); + +export const OutfitStateContext = React.createContext(null); + +function useOutfitState() { + const apolloClient = useApolloClient(); + const urlOutfitState = useParseOutfitUrl(); + const [localOutfitState, dispatchToOutfit] = React.useReducer( + outfitStateReducer(apolloClient), + urlOutfitState + ); + + // If there's an outfit ID (i.e. we're on /outfits/:id), load basic data + // about the outfit. We'll use it to initialize the local state. + const { + loading: outfitLoading, + error: outfitError, + data: outfitData, + } = useQuery( + gql` + query OutfitStateSavedOutfit($id: ID!) { + outfit(id: $id) { + id + name + updatedAt + creator { + id + } + petAppearance { + id + species { + id + } + color { + id + } + pose + } + wornItems { + id + } + closetedItems { + id + } + + # TODO: Consider pre-loading some fields, instead of doing them in + # follow-up queries? + } + } + `, + { + variables: { id: urlOutfitState.id }, + skip: urlOutfitState.id == null, + returnPartialData: true, + onCompleted: (outfitData) => { + dispatchToOutfit({ + type: "resetToSavedOutfitData", + savedOutfitData: outfitData.outfit, + }); + }, + } + ); + + const creator = outfitData?.outfit?.creator; + const updatedAt = outfitData?.outfit?.updatedAt; + + // We memoize this to make `outfitStateWithoutExtras` an even more reliable + // stable object! + const savedOutfitState = React.useMemo( + () => getOutfitStateFromOutfitData(outfitData?.outfit), + [outfitData?.outfit] + ); + + // Choose which customization state to use. We want it to match the outfit in + // the URL immediately, without having to wait for any effects, to avoid race + // conditions! + // + // The reducer is generally the main source of truth for live changes! + // + // But if: + // - it's not initialized yet (e.g. the first frame of navigating to an + // outfit from Your Outfits), or + // - it's for a different outfit than the URL says (e.g. clicking Back + // or Forward to switch between saved outfits), + // + // Then use saved outfit data or the URL query string instead, because that's + // a better representation of the outfit in the URL. (If the saved outfit + // data isn't loaded yet, then this will be a customization state with + // partial data, and that's okay.) + let outfitState; + if ( + urlOutfitState.id === localOutfitState.id && + localOutfitState.speciesId != null && + localOutfitState.colorId != null + ) { + // Use the reducer state: they're both for the same saved outfit, or both + // for an unsaved outfit (null === null). But we don't use it when it's + // *only* got the ID, and no other fields yet. + console.debug("[useOutfitState] Choosing local outfit state"); + outfitState = localOutfitState; + } else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) { + // Use the saved outfit state: it's for the saved outfit the URL points to. + console.debug("[useOutfitState] Choosing saved outfit state"); + outfitState = savedOutfitState; + } else { + // Use the URL state: it's more up-to-date than any of the others. (Worst + // case, it's empty except for ID, which is fine while the saved outfit + // data loads!) + console.debug("[useOutfitState] Choosing URL outfit state"); + outfitState = urlOutfitState; + } + + // When unpacking the customization state, we call `Array.from` on our item + // IDs. It's more convenient to manage them as a Set in state, but most + // callers will find it more convenient to access them as arrays! e.g. for + // `.map()`. + const { id, name, speciesId, colorId, pose, appearanceId } = outfitState; + const wornItemIds = Array.from(outfitState.wornItemIds); + const closetedItemIds = Array.from(outfitState.closetedItemIds); + const allItemIds = [...wornItemIds, ...closetedItemIds]; + + const { + loading: itemsLoading, + error: itemsError, + data: itemsData, + } = useQuery( + gql` + query OutfitStateItems( + $allItemIds: [ID!]! + $speciesId: ID! + $colorId: ID! + ) { + items(ids: $allItemIds) { + # TODO: De-dupe this from SearchPanel? + id + name + thumbnailUrl + isNc + isPb + currentUserOwnsThis + currentUserWantsThis + + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + # This enables us to quickly show the item when the user clicks it! + ...ItemAppearanceForOutfitPreview + + # This is used to group items by zone, and to detect conflicts when + # wearing a new item. + layers { + zone { + id + label @client + } + } + restrictedZones { + id + label @client + isCommonlyUsedByItems @client + } + } + } + + # NOTE: We skip this query if items is empty for perf reasons. If + # you're adding more fields, consider changing that condition! + } + ${itemAppearanceFragment} + `, + { + variables: { allItemIds, speciesId, colorId }, + context: { sendAuth: true }, + // Skip if this outfit has no items, as an optimization; or if we don't + // have the species/color ID loaded yet because we're waiting on the + // saved outfit to load. + skip: allItemIds.length === 0 || speciesId == null || colorId == null, + } + ); + + const resultItems = itemsData?.items || []; + + // Okay, time for some big perf hacks! Lower down in the app, we use + // React.memo to avoid re-rendering Item components if the items haven't + // updated. In simpler cases, we just make the component take the individual + // item fields as props... but items are complex and that makes it annoying + // :p Instead, we do these tricks to reuse physical item objects if they're + // still deep-equal to the previous version. This is because React.memo uses + // object identity to compare its props, so now when it checks whether + // `oldItem === newItem`, the answer will be `true`, unless the item really + // _did_ change! + const [cachedItemObjects, setCachedItemObjects] = React.useState([]); + let items = resultItems.map((item) => { + const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id); + if ( + cachedItemObject && + JSON.stringify(cachedItemObject) === JSON.stringify(item) + ) { + return cachedItemObject; + } + return item; + }); + if ( + items.length === cachedItemObjects.length && + items.every((_, index) => items[index] === cachedItemObjects[index]) + ) { + // Even reuse the entire array if none of the items changed! + items = cachedItemObjects; + } + React.useEffect(() => { + setCachedItemObjects(items); + }, [items, setCachedItemObjects]); + + const itemsById = {}; + for (const item of items) { + itemsById[item.id] = item; + } + + const zonesAndItems = getZonesAndItems( + itemsById, + wornItemIds, + closetedItemIds + ); + const incompatibleItems = items + .filter((i) => i.appearanceOn.layers.length === 0) + .sort((a, b) => a.name.localeCompare(b.name)); + + const url = buildOutfitUrl(outfitState); + + const outfitStateWithExtras = { + id, + creator, + updatedAt, + zonesAndItems, + incompatibleItems, + name, + wornItemIds, + closetedItemIds, + allItemIds, + speciesId, + colorId, + pose, + appearanceId, + url, + + // We use this plain outfit state objects in `useOutfitSaving`! Unlike the + // full `outfitState` object, which we rebuild each render, + // `outfitStateWithoutExtras` will mostly only change when there is an + // actual change to outfit state. + outfitStateWithoutExtras: outfitState, + savedOutfitState, + }; + + // Keep the URL up-to-date. (We don't listen to it, though 😅) + // TODO: Seems like we should hook this in with the actual router... I'm + // avoiding it rn, but I'm worried Next.js won't necessarily play nice + // with this hack, even though react-router did. Hard to predict! + React.useEffect(() => { + if (typeof history !== "undefined") { + history.replaceState(null, "", url); + } + }, [url]); + + return { + loading: outfitLoading || itemsLoading, + error: outfitError || itemsError, + outfitState: outfitStateWithExtras, + dispatchToOutfit, + }; +} + +const outfitStateReducer = (apolloClient) => (baseState, action) => { + console.info("[useOutfitState] Action:", action); + switch (action.type) { + case "rename": + return produce(baseState, (state) => { + state.name = action.outfitName; + }); + case "setSpeciesAndColor": + return produce(baseState, (state) => { + state.speciesId = action.speciesId; + state.colorId = action.colorId; + state.pose = action.pose; + state.appearanceId = null; + }); + case "wearItem": + return produce(baseState, (state) => { + const { wornItemIds, closetedItemIds } = state; + const { itemId, itemIdsToReconsider = [] } = action; + + // Move conflicting items to the closet. + // + // We do this by looking them up in the Apollo Cache, which is going to + // include the relevant item data because the `useOutfitState` hook + // queries for it! + // + // (It could be possible to mess up the timing by taking an action + // while worn items are still partially loading, but I think it would + // require a pretty weird action sequence to make that happen... like, + // doing a search and it loads before the worn item data does? Anyway, + // Apollo will throw in that case, which should just essentially reject + // the action.) + let conflictingIds; + try { + conflictingIds = findItemConflicts(itemId, state, apolloClient); + } catch (e) { + console.error(e); + return; + } + for (const conflictingId of conflictingIds) { + wornItemIds.delete(conflictingId); + closetedItemIds.add(conflictingId); + } + + // Move this item from the closet to the worn set. + closetedItemIds.delete(itemId); + wornItemIds.add(itemId); + + reconsiderItems(itemIdsToReconsider, state, apolloClient); + }); + case "unwearItem": + return produce(baseState, (state) => { + const { wornItemIds, closetedItemIds } = state; + const { itemId, itemIdsToReconsider = [] } = action; + + // Move this item from the worn set to the closet. + wornItemIds.delete(itemId); + closetedItemIds.add(itemId); + + reconsiderItems( + // Don't include the unworn item in items to reconsider! + itemIdsToReconsider.filter((x) => x !== itemId), + state, + apolloClient + ); + }); + case "removeItem": + return produce(baseState, (state) => { + const { wornItemIds, closetedItemIds } = state; + const { itemId, itemIdsToReconsider = [] } = action; + + // Remove this item from both the worn set and the closet. + wornItemIds.delete(itemId); + closetedItemIds.delete(itemId); + + reconsiderItems( + // Don't include the removed item in items to reconsider! + itemIdsToReconsider.filter((x) => x !== itemId), + state, + apolloClient + ); + }); + case "setPose": + return produce(baseState, (state) => { + state.pose = action.pose; + // Usually only the `pose` is specified, but `PosePickerSupport` can + // also specify a corresponding `appearanceId`, to get even more + // particular about which version of the pose to show if more than one. + state.appearanceId = action.appearanceId || null; + }); + case "resetToSavedOutfitData": + return getOutfitStateFromOutfitData(action.savedOutfitData); + default: + throw new Error(`unexpected action ${JSON.stringify(action)}`); + } +}; + +const EMPTY_CUSTOMIZATION_STATE = { + id: null, + name: null, + speciesId: null, + colorId: null, + pose: null, + appearanceId: null, + wornItemIds: [], + closetedItemIds: [], +}; + +function useParseOutfitUrl() { + const { query } = useRouter(); + + // We memoize this to make `outfitStateWithoutExtras` an even more reliable + // stable object! + const memoizedOutfitState = React.useMemo( + () => readOutfitStateFromQuery(query), + [query] + ); + + return memoizedOutfitState; +} + +export function readOutfitStateFromQuery(query) { + // For the /outfits/:id page, ignore the query string, and just wait for the + // outfit data to load in! + if (query.outfitId != null) { + return { + ...EMPTY_CUSTOMIZATION_STATE, + id: query.outfitId, + }; + } + + // Otherwise, parse the query string, and fill in default values for anything + // not specified. + return { + id: null, + name: getValueFromQuery(query.name), + speciesId: getValueFromQuery(query.species) || "1", + colorId: getValueFromQuery(query.color) || "8", + pose: getValueFromQuery(query.pose) || "HAPPY_FEM", + appearanceId: getValueFromQuery(query.state) || null, + wornItemIds: new Set(getListFromQuery(query["objects[]"])), + closetedItemIds: new Set(getListFromQuery(query["closet[]"])), + }; +} + +/** + * getValueFromQuery reads the given value from Next's `router.query` as a + * single value. For example: + * + * ?foo=bar -> "bar" -> "bar" + * ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar" + * ?lol=huh -> undefined -> null + */ +function getValueFromQuery(value) { + if (Array.isArray(value)) { + return value[0]; + } else if (value != null) { + return value; + } else { + return null; + } +} + +/** + * getListFromQuery reads the given value from Next's `router.query` as a list + * of values. For example: + * + * ?foo=bar -> "bar" -> ["bar"] + * ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"] + * ?lol=huh -> undefined -> [] + */ +function getListFromQuery(value) { + if (Array.isArray(value)) { + return value; + } else if (value != null) { + return [value]; + } else { + return []; + } +} + +function getOutfitStateFromOutfitData(outfit) { + if (!outfit) { + return EMPTY_CUSTOMIZATION_STATE; + } + + return { + id: outfit.id, + name: outfit.name, + // Note that these fields are intentionally null if loading, rather than + // falling back to a default appearance like Blue Acara. + speciesId: outfit.petAppearance?.species?.id, + colorId: outfit.petAppearance?.color?.id, + pose: outfit.petAppearance?.pose, + // Whereas the items are more convenient to just leave as empty lists! + wornItemIds: new Set((outfit.wornItems || []).map((item) => item.id)), + closetedItemIds: new Set( + (outfit.closetedItems || []).map((item) => item.id) + ), + }; +} + +function findItemConflicts(itemIdToAdd, state, apolloClient) { + const { wornItemIds, speciesId, colorId } = state; + + const { items } = apolloClient.readQuery({ + query: gql` + query OutfitStateItemConflicts( + $itemIds: [ID!]! + $speciesId: ID! + $colorId: ID! + ) { + items(ids: $itemIds) { + id + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + layers { + zone { + id + } + } + + restrictedZones { + id + } + } + } + } + `, + variables: { + itemIds: [itemIdToAdd, ...wornItemIds], + speciesId, + colorId, + }, + }); + const itemToAdd = items.find((i) => i.id === itemIdToAdd); + if (!itemToAdd.appearanceOn) { + return []; + } + const wornItems = Array.from(wornItemIds).map((id) => + items.find((i) => i.id === id) + ); + + const itemToAddZoneSets = getItemZones(itemToAdd); + + const conflictingIds = []; + for (const wornItem of wornItems) { + if (!wornItem.appearanceOn) { + continue; + } + + const wornItemZoneSets = getItemZones(wornItem); + + const itemsConflict = + setsIntersect( + itemToAddZoneSets.occupies, + wornItemZoneSets.occupiesOrRestricts + ) || + setsIntersect( + wornItemZoneSets.occupies, + itemToAddZoneSets.occupiesOrRestricts + ); + + if (itemsConflict) { + conflictingIds.push(wornItem.id); + } + } + + return conflictingIds; +} + +function getItemZones(item) { + const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id)); + const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id)); + const occupiesOrRestricts = new Set([...occupies, ...restricts]); + return { occupies, occupiesOrRestricts }; +} + +function setsIntersect(a, b) { + for (const el of a) { + if (b.has(el)) { + return true; + } + } + return false; +} + +/** + * Try to add these items back to the outfit, if there would be no conflicts. + * We use this in Search to try to restore these items after the user makes + * changes, e.g., after they try on another Background we want to restore the + * previous one! + * + * This mutates state.wornItemIds directly, on the assumption that we're in an + * immer block, in which case mutation is the simplest API! + */ +function reconsiderItems(itemIdsToReconsider, state, apolloClient) { + for (const itemIdToReconsider of itemIdsToReconsider) { + const conflictingIds = findItemConflicts( + itemIdToReconsider, + state, + apolloClient + ); + if (conflictingIds.length === 0) { + state.wornItemIds.add(itemIdToReconsider); + } + } +} + +// TODO: Get this out of here, tbh... +function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) { + const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i); + const closetedItems = closetedItemIds + .map((id) => itemsById[id]) + .filter((i) => i); + + // We use zone label here, rather than ID, because some zones have the same + // label and we *want* to over-simplify that in this UI. (e.g. there are + // multiple Hat zones, and some items occupy different ones, but mostly let's + // just group them and if they don't conflict then all the better!) + const allItems = [...wornItems, ...closetedItems]; + const itemsByZoneLabel = new Map(); + for (const item of allItems) { + if (!item.appearanceOn) { + continue; + } + + for (const layer of item.appearanceOn.layers) { + const zoneLabel = layer.zone.label; + + if (!itemsByZoneLabel.has(zoneLabel)) { + itemsByZoneLabel.set(zoneLabel, []); + } + itemsByZoneLabel.get(zoneLabel).push(item); + } + } + + let zonesAndItems = Array.from(itemsByZoneLabel.entries()).map( + ([zoneLabel, items]) => ({ + zoneLabel: zoneLabel, + items: [...items].sort((a, b) => a.name.localeCompare(b.name)), + }) + ); + zonesAndItems.sort((a, b) => a.zoneLabel.localeCompare(b.zoneLabel)); + + // As one last step, try to remove zone groups that aren't helpful. + const groupsWithConflicts = zonesAndItems.filter( + ({ items }) => items.length > 1 + ); + const itemIdsWithConflicts = new Set( + groupsWithConflicts + .map(({ items }) => items) + .flat() + .map((item) => item.id) + ); + const itemIdsWeHaveSeen = new Set(); + zonesAndItems = zonesAndItems.filter(({ items }) => { + // We need all groups with more than one item. If there's only one, we get + // to think harder :) + if (items.length > 1) { + items.forEach((item) => itemIdsWeHaveSeen.add(item.id)); + return true; + } + + const item = items[0]; + + // Has the item been seen a group we kept, or an upcoming group with + // multiple conflicting items? If so, skip this group. If not, keep it. + if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) { + return false; + } else { + itemIdsWeHaveSeen.add(item.id); + return true; + } + }); + + return zonesAndItems; +} + +export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) { + const { id } = outfitState; + + const origin = + typeof window !== "undefined" + ? window.location.origin + : "https://impress-2020.openneo.net"; + + if (id && !withoutOutfitId) { + return origin + `/outfits/${id}`; + } + + return origin + "/outfits/new?" + buildOutfitQueryString(outfitState); +} + +function buildOutfitQueryString(outfitState) { + const { + name, + speciesId, + colorId, + pose, + appearanceId, + wornItemIds, + closetedItemIds, + } = outfitState; + + const params = new URLSearchParams({ + name: name || "", + species: speciesId || "", + color: colorId || "", + pose: pose || "", + }); + for (const itemId of wornItemIds) { + params.append("objects[]", itemId); + } + for (const itemId of closetedItemIds) { + params.append("closet[]", itemId); + } + if (appearanceId != null) { + // `state` is an old name for compatibility with old-style DTI URLs. It + // refers to "PetState", the database table name for pet appearances. + params.append("state", appearanceId); + } + + return params.toString(); +} + +/** + * Whether the two given outfit states represent identical customizations. + */ +export function outfitStatesAreEqual(a, b) { + return buildOutfitQueryString(a) === buildOutfitQueryString(b); +} + +export default useOutfitState; diff --git a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js new file mode 100644 index 00000000..1dea94fc --- /dev/null +++ b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js @@ -0,0 +1,137 @@ +import gql from "graphql-tag"; +import { useQuery } from "@apollo/client"; +import { useDebounce } from "../util"; +import { emptySearchQuery } from "./SearchToolbar"; +import { itemAppearanceFragment } from "../components/useOutfitAppearance"; +import { SEARCH_PER_PAGE } from "./SearchPanel"; + +/** + * useSearchResults manages the actual querying and state management of search! + */ +export function useSearchResults( + query, + outfitState, + currentPageNumber, + { skip = false } = {} +) { + const { speciesId, colorId } = outfitState; + + // We debounce the search query, so that we don't resend a new query whenever + // the user types anything. + const debouncedQuery = useDebounce(query, 300, { + waitForFirstPause: true, + initialValue: emptySearchQuery, + }); + + // NOTE: This query should always load ~instantly, from the client cache. + const { data: zoneData } = useQuery(gql` + query SearchPanelZones { + allZones { + id + label + } + } + `); + const allZones = zoneData?.allZones || []; + const filterToZones = query.filterToZoneLabel + ? allZones.filter((z) => z.label === query.filterToZoneLabel) + : []; + const filterToZoneIds = filterToZones.map((z) => z.id); + + const currentPageIndex = currentPageNumber - 1; + const offset = currentPageIndex * SEARCH_PER_PAGE; + + // Here's the actual GQL query! At the bottom we have more config than usual! + const { + loading: loadingGQL, + error, + data, + } = useQuery( + gql` + query SearchPanel( + $query: String! + $fitsPet: FitsPetSearchFilter + $itemKind: ItemKindSearchFilter + $currentUserOwnsOrWants: OwnsOrWants + $zoneIds: [ID!]! + $speciesId: ID! + $colorId: ID! + $offset: Int! + $perPage: Int! + ) { + itemSearch: itemSearchV2( + query: $query + fitsPet: $fitsPet + itemKind: $itemKind + currentUserOwnsOrWants: $currentUserOwnsOrWants + zoneIds: $zoneIds + ) { + id + numTotalItems + items(offset: $offset, limit: $perPage) { + # TODO: De-dupe this from useOutfitState? + id + name + thumbnailUrl + isNc + isPb + currentUserOwnsThis + currentUserWantsThis + + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + # This enables us to quickly show the item when the user clicks it! + ...ItemAppearanceForOutfitPreview + + # This is used to group items by zone, and to detect conflicts when + # wearing a new item. + layers { + zone { + id + label @client + } + } + restrictedZones { + id + label @client + isCommonlyUsedByItems @client + } + } + } + } + } + ${itemAppearanceFragment} + `, + { + variables: { + query: debouncedQuery.value, + fitsPet: { speciesId, colorId }, + itemKind: debouncedQuery.filterToItemKind, + currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants, + zoneIds: filterToZoneIds, + speciesId, + colorId, + offset, + perPage: SEARCH_PER_PAGE, + }, + context: { sendAuth: true }, + skip: + skip || + (!debouncedQuery.value && + !debouncedQuery.filterToItemKind && + !debouncedQuery.filterToZoneLabel && + !debouncedQuery.filterToCurrentUserOwnsOrWants), + onError: (e) => { + console.error("Error loading search results", e); + }, + // Return `numTotalItems` from the GQL cache while waiting for next page! + returnPartialData: true, + } + ); + + const loading = debouncedQuery !== query || loadingGQL; + const items = data?.itemSearch?.items ?? []; + const numTotalItems = data?.itemSearch?.numTotalItems ?? null; + const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE); + + return { loading, error, items, numTotalPages }; +} diff --git a/app/javascript/wardrobe-2020/components/HTML5Badge.js b/app/javascript/wardrobe-2020/components/HTML5Badge.js new file mode 100644 index 00000000..2b9132d4 --- /dev/null +++ b/app/javascript/wardrobe-2020/components/HTML5Badge.js @@ -0,0 +1,147 @@ +import React from "react"; +import { Tooltip, useColorModeValue, Flex, Icon } from "@chakra-ui/react"; +import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons"; + +function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) { + // `delayedUsesHTML5` stores the last known value of `usesHTML5`, when + // `isLoading` was `false`. This enables us to keep showing the badge, even + // when loading a new appearance - because it's unlikely the badge will + // change between different appearances for the same item, and the flicker is + // annoying! + const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null); + React.useEffect(() => { + if (!isLoading) { + setDelayedUsesHTML5(usesHTML5); + } + }, [usesHTML5, isLoading]); + + if (delayedUsesHTML5 === true) { + return ( + + + + {/* From Twemoji Keycap 5 */} + + + + ); + } else if (delayedUsesHTML5 === false) { + return ( + + This item isn't converted to HTML5 yet, so it might not appear in + Neopets.com customization yet. Once it's ready, it could look a + bit different than our temporary preview here. It might even be + animated! + + ) + } + > + + + {/* From Twemoji Keycap 5 */} + + + {/* From Twemoji Not Allowed */} + + + + ); + } else { + // If no `usesHTML5` value has been provided yet, we're empty for now! + return null; + } +} + +export function GlitchBadgeLayout({ + hasGlitches = true, + children, + tooltipLabel, + ...props +}) { + const [isHovered, setIsHovered] = React.useState(false); + const [isFocused, setIsFocused] = React.useState(false); + + const greenBackground = useColorModeValue("green.100", "green.900"); + const greenBorderColor = useColorModeValue("green.600", "green.500"); + const greenTextColor = useColorModeValue("green.700", "white"); + + const yellowBackground = useColorModeValue("yellow.100", "yellow.900"); + const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500"); + const yellowTextColor = useColorModeValue("yellow.700", "white"); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + {...props} + > + {children} + + + ); +} + +export function layerUsesHTML5(layer) { + return Boolean( + layer.svgUrl || + layer.canvasMovieLibraryUrl || + // If this glitch is applied, then `svgUrl` will be null, but there's still + // an HTML5 manifest that the official player can render. + (layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT") + ); +} + +export default HTML5Badge; diff --git a/app/javascript/wardrobe-2020/components/HangerSpinner.js b/app/javascript/wardrobe-2020/components/HangerSpinner.js new file mode 100644 index 00000000..4faf9613 --- /dev/null +++ b/app/javascript/wardrobe-2020/components/HangerSpinner.js @@ -0,0 +1,97 @@ +import * as React from "react"; +import { ClassNames } from "@emotion/react"; +import { Box, useColorModeValue } from "@chakra-ui/react"; +import { createIcon } from "@chakra-ui/icons"; + +const HangerIcon = createIcon({ + displayName: "HangerIcon", + + // https://www.svgrepo.com/svg/108090/clothes-hanger + viewBox: "0 0 473 473", + path: ( + + ), +}); + +function HangerSpinner({ size = "md", ...props }) { + const boxSize = { sm: "32px", md: "48px" }[size]; + const color = useColorModeValue("green.500", "green.300"); + + return ( + + {({ css }) => ( + + + + )} + + ); +} + +export default HangerSpinner; diff --git a/app/javascript/wardrobe-2020/components/ItemCard.js b/app/javascript/wardrobe-2020/components/ItemCard.js new file mode 100644 index 00000000..b87237ec --- /dev/null +++ b/app/javascript/wardrobe-2020/components/ItemCard.js @@ -0,0 +1,432 @@ +import React from "react"; +import { ClassNames } from "@emotion/react"; +import { + Badge, + Box, + SimpleGrid, + Tooltip, + Wrap, + WrapItem, + useColorModeValue, + useTheme, +} from "@chakra-ui/react"; +import { + CheckIcon, + EditIcon, + NotAllowedIcon, + StarIcon, +} from "@chakra-ui/icons"; +import { HiSparkles } from "react-icons/hi"; +import Link from "next/link"; + +import SquareItemCard from "./SquareItemCard"; +import { safeImageUrl, useCommonStyles } from "../util"; +import usePreferArchive from "./usePreferArchive"; + +function ItemCard({ item, badges, variant = "list", ...props }) { + const { brightBackground } = useCommonStyles(); + + switch (variant) { + case "grid": + return ; + case "list": + return ( + + + + + + ); + default: + throw new Error(`Unexpected ItemCard variant: ${variant}`); + } +} + +export function ItemCardContent({ + item, + badges, + isWorn, + isDisabled, + itemNameId, + focusSelector, +}) { + return ( + + + + + + + + + {item.name} + + + {badges} + + + ); +} + +/** + * ItemThumbnail shows a small preview image for the item, including some + * hover/focus and worn/unworn states. + */ +export function ItemThumbnail({ + item, + size = "md", + isActive, + isDisabled, + focusSelector, + ...props +}) { + const [preferArchive] = usePreferArchive(); + const theme = useTheme(); + + const borderColor = useColorModeValue( + theme.colors.green["700"], + "transparent" + ); + + const focusBorderColor = useColorModeValue( + theme.colors.green["600"], + "transparent" + ); + + return ( + + {({ css }) => ( + + + {/* If the item is still loading, wait with an empty box. */} + {item && ( + + )} + + + )} + + ); +} + +/** + * ItemName shows the item's name, including some hover/focus and worn/unworn + * states. + */ +function ItemName({ children, isDisabled, focusSelector, ...props }) { + const theme = useTheme(); + + return ( + + {({ css }) => ( + + {children} + + )} + + ); +} + +export function ItemCardList({ children }) { + return ( + + {children} + + ); +} + +export function ItemBadgeList({ children, ...props }) { + return ( + + {React.Children.map( + children, + (badge) => badge && {badge} + )} + + ); +} + +export function ItemBadgeTooltip({ label, children }) { + return ( + {label}} + placement="top" + openDelay={400} + > + {children} + + ); +} + +export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { + return ( + + + NC + {isEditButton && } + + + ); +}); + +export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { + return ( + + + NP + {isEditButton && } + + + ); +}); + +export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { + return ( + + + PB + {isEditButton && } + + + ); +}); + +export const ItemKindBadge = React.forwardRef( + ({ isNc, isPb, isEditButton, ...props }, ref) => { + if (isNc) { + return ; + } else if (isPb) { + return ; + } else { + return ; + } + } +); + +export function YouOwnThisBadge({ variant = "long" }) { + let badge = ( + + + {variant === "medium" && Own} + {variant === "long" && You own this!} + + ); + + if (variant === "short" || variant === "medium") { + badge = ( + {badge} + ); + } + + return badge; +} + +export function YouWantThisBadge({ variant = "long" }) { + let badge = ( + + + {variant === "medium" && Want} + {variant === "long" && You want this!} + + ); + + if (variant === "short" || variant === "medium") { + badge = ( + {badge} + ); + } + + return badge; +} + +function ZoneBadge({ variant, zoneLabel }) { + // Shorten the label when necessary, to make the badges less bulky + const shorthand = zoneLabel + .replace("Background Item", "BG Item") + .replace("Foreground Item", "FG Item") + .replace("Lower-body", "Lower") + .replace("Upper-body", "Upper") + .replace("Transient", "Trans") + .replace("Biology", "Bio"); + + if (variant === "restricts") { + return ( + + + + {shorthand} + + + + ); + } + + if (shorthand !== zoneLabel) { + return ( + + {shorthand} + + ); + } + + return {shorthand}; +} + +export function getZoneBadges(zones, propsForAllBadges) { + // Get the sorted zone labels. Sometimes an item occupies multiple zones of + // the same name, so it's important to de-duplicate them! + let labels = zones.map((z) => z.label); + labels = new Set(labels); + labels = [...labels].sort(); + + return labels.map((label) => ( + + )); +} + +export function MaybeAnimatedBadge() { + return ( + + + + + + ); +} + +export default ItemCard; diff --git a/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js b/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js new file mode 100644 index 00000000..ae9d016d --- /dev/null +++ b/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js @@ -0,0 +1,471 @@ +import React from "react"; +import LRU from "lru-cache"; +import { Box, Grid, useToast } from "@chakra-ui/react"; + +import { loadImage, logAndCapture, safeImageUrl } from "../util"; +import usePreferArchive from "./usePreferArchive"; + +// Importing EaselJS and TweenJS puts them directly into the `window` object! +// The bundled scripts are built to attach themselves to `window.createjs`, and +// `window.createjs` is where the Neopets movie libraries expects to find them! +import "easeljs/lib/easeljs"; +import "tweenjs/lib/tweenjs"; + +function OutfitMovieLayer({ + libraryUrl, + width, + height, + placeholderImageUrl = null, + isPaused = false, + onLoad = null, + onError = null, + onLowFps = null, + canvasProps = {}, +}) { + const [preferArchive] = usePreferArchive(); + const [stage, setStage] = React.useState(null); + const [library, setLibrary] = React.useState(null); + const [movieClip, setMovieClip] = React.useState(null); + const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false); + const [movieIsLoaded, setMovieIsLoaded] = React.useState(false); + const canvasRef = React.useRef(null); + const hasShownErrorMessageRef = React.useRef(false); + const toast = useToast(); + + // Set the canvas's internal dimensions to be higher, if the device has high + // DPI like retina. But we'll keep the layout width/height as expected! + const internalWidth = width * window.devicePixelRatio; + const internalHeight = height * window.devicePixelRatio; + + const callOnLoadIfNotYetCalled = React.useCallback(() => { + setHasCalledOnLoad((alreadyHasCalledOnLoad) => { + if (!alreadyHasCalledOnLoad && onLoad) { + onLoad(); + } + return true; + }); + }, [onLoad]); + + const updateStage = React.useCallback(() => { + if (!stage) { + return; + } + + try { + stage.update(); + } catch (e) { + // If rendering the frame fails, log it and proceed. If it's an + // animation, then maybe the next frame will work? Also alert the user, + // just as an FYI. (This is pretty uncommon, so I'm not worried about + // being noisy!) + if (!hasShownErrorMessageRef.current) { + console.error(`Error rendering movie clip ${libraryUrl}`); + logAndCapture(e); + toast({ + status: "warning", + title: + "Hmm, we're maybe having trouble playing one of these animations.", + description: + "If it looks wrong, try pausing and playing, or reloading the " + + "page. Sorry!", + duration: 10000, + isClosable: true, + }); + // We do this via a ref, not state, because I want to guarantee that + // future calls see the new value. With state, React's effects might + // not happen in the right order for it to work! + hasShownErrorMessageRef.current = true; + } + } + }, [stage, toast, libraryUrl]); + + // This effect gives us a `stage` corresponding to the canvas element. + React.useLayoutEffect(() => { + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + + if (canvas.getContext("2d") == null) { + console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`); + toast({ + status: "warning", + title: "Oops, too many animations!", + description: + `Your device is out of memory, so we can't show any more ` + + `animations. Try removing some items, or using another device.`, + duration: null, + isClosable: true, + }); + return; + } + + setStage((stage) => { + if (stage && stage.canvas === canvas) { + return stage; + } + + return new window.createjs.Stage(canvas); + }); + + return () => { + setStage(null); + + if (canvas) { + // There's a Safari bug where it doesn't reliably garbage-collect + // canvas data. Clean it up ourselves, rather than leaking memory over + // time! https://stackoverflow.com/a/52586606/107415 + // https://bugs.webkit.org/show_bug.cgi?id=195325 + canvas.width = 0; + canvas.height = 0; + } + }; + }, [libraryUrl, toast]); + + // This effect gives us the `library` and `movieClip`, based on the incoming + // `libraryUrl`. + React.useEffect(() => { + let canceled = false; + + const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive }); + movieLibraryPromise + .then((library) => { + if (canceled) { + return; + } + + setLibrary(library); + + const movieClip = buildMovieClip(library, libraryUrl); + setMovieClip(movieClip); + }) + .catch((e) => { + console.error(`Error loading outfit movie layer: ${libraryUrl}`, e); + if (onError) { + onError(e); + } + }); + + return () => { + canceled = true; + movieLibraryPromise.cancel(); + setLibrary(null); + setMovieClip(null); + }; + }, [libraryUrl, preferArchive, onError]); + + // This effect puts the `movieClip` on the `stage`, when both are ready. + React.useEffect(() => { + if (!stage || !movieClip) { + return; + } + + stage.addChild(movieClip); + + // Render the movie's first frame. If it's animated and we're not paused, + // then another effect will perform subsequent updates. + updateStage(); + + // This is when we trigger `onLoad`: once we're actually showing it! + callOnLoadIfNotYetCalled(); + setMovieIsLoaded(true); + + return () => stage.removeChild(movieClip); + }, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]); + + // This effect updates the `stage` according to the `library`'s framerate, + // but only if there's actual animation to do - i.e., there's more than one + // frame to show, and we're not paused. + React.useEffect(() => { + if (!stage || !movieClip || !library) { + return; + } + + if (isPaused || !hasAnimations(movieClip)) { + return; + } + + const targetFps = library.properties.fps; + + let lastFpsLoggedAtInMs = performance.now(); + let numFramesSinceLastLogged = 0; + const intervalId = setInterval(() => { + updateStage(); + + numFramesSinceLastLogged++; + + const now = performance.now(); + const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs; + const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000; + + if (timeSinceLastFpsLoggedAtInSec > 2) { + const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec; + const roundedFps = Math.round(fps * 100) / 100; + + console.debug( + `[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})` + ); + + if (onLowFps && fps < 2) { + onLowFps(fps); + } + + lastFpsLoggedAtInMs = now; + numFramesSinceLastLogged = 0; + } + }, 1000 / targetFps); + + return () => clearInterval(intervalId); + }, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]); + + // This effect keeps the `movieClip` scaled correctly, based on the canvas + // size and the `library`'s natural size declaration. (If the canvas size + // changes on window resize, then this will keep us responsive, so long as + // the parent updates our width/height props on window resize!) + React.useEffect(() => { + if (!stage || !movieClip || !library) { + return; + } + + movieClip.scaleX = internalWidth / library.properties.width; + movieClip.scaleY = internalHeight / library.properties.height; + + // Redraw the stage with the new dimensions - but with `tickOnUpdate` set + // to `false`, so that we don't advance by a frame. This keeps us + // really-paused if we're paused, and avoids skipping ahead by a frame if + // we're playing. + stage.tickOnUpdate = false; + updateStage(); + stage.tickOnUpdate = true; + }, [stage, updateStage, library, movieClip, internalWidth, internalHeight]); + + return ( + + + {/* While the movie is loading, we show our image version as a + * placeholder, because it generally loads much faster. + * TODO: Show a loading indicator for this partially-loaded state? */} + {placeholderImageUrl && ( + + )} + + ); +} + +function loadScriptTag(src) { + let script; + let canceled = false; + let resolved = false; + + const scriptTagPromise = new Promise((resolve, reject) => { + script = document.createElement("script"); + script.onload = () => { + if (canceled) return; + resolved = true; + resolve(script); + }; + script.onerror = (e) => { + if (canceled) return; + reject(new Error(`Failed to load script: ${JSON.stringify(src)}`)); + }; + script.src = src; + document.body.appendChild(script); + }); + + scriptTagPromise.cancel = () => { + if (resolved) return; + script.src = ""; + canceled = true; + }; + + return scriptTagPromise; +} + +const MOVIE_LIBRARY_CACHE = new LRU(10); + +export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) { + const cancelableResourcePromises = []; + const cancelAllResources = () => + cancelableResourcePromises.forEach((p) => p.cancel()); + + // Most of the logic for `loadMovieLibrary` is inside this async function. + // But we want to attach more fields to the promise before returning it; so + // we declare this async function separately, then call it, then edit the + // returned promise! + const createMovieLibraryPromise = async () => { + // First, check the LRU cache. This will enable us to quickly return movie + // libraries, without re-loading and re-parsing and re-executing. + const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc); + if (cachedLibrary) { + return cachedLibrary; + } + + // Then, load the script tag. (Make sure we set it up to be cancelable!) + const scriptPromise = loadScriptTag( + safeImageUrl(librarySrc, { preferArchive }) + ); + cancelableResourcePromises.push(scriptPromise); + await scriptPromise; + + // These library JS files are interesting in their operation. It seems like + // the idea is, it pushes an object to a global array, and you need to snap + // it up and see it at the end of the array! And I don't really see a way to + // like, get by a name or ID that we know by this point. So, here we go, just + // try to grab it once it arrives! + // + // I'm not _sure_ this method is reliable, but it seems to be stable so far + // in Firefox for me. The things I think I'm observing are: + // - Script execution order should match insert order, + // - Onload execution order should match insert order, + // - BUT, script executions might be batched before onloads. + // - So, each script grabs the _first_ composition from the list, and + // deletes it after grabbing. That way, it serves as a FIFO queue! + // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing + // the race anymore? But fingers crossed! + if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) { + throw new Error( + `Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.` + ); + } + const [compositionId, composition] = Object.entries( + window.AdobeAn.compositions + )[0]; + if (Object.keys(window.AdobeAn.compositions).length > 1) { + console.warn( + `Grabbing composition ${compositionId}, but there are >1 here: `, + Object.keys(window.AdobeAn.compositions).length + ); + } + delete window.AdobeAn.compositions[compositionId]; + const library = composition.getLibrary(); + + // One more loading step as part of loading this library is loading the + // images it uses for sprites. + // + // TODO: I guess the manifest has these too, so if we could use our DB cache + // to get the manifest to us faster, then we could avoid a network RTT + // on the critical path by preloading these images before the JS file + // even gets to us? + const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/"); + const manifestImages = new Map( + library.properties.manifest.map(({ id, src }) => [ + id, + loadImage(librarySrcDir + "/" + src, { + crossOrigin: "anonymous", + preferArchive, + }), + ]) + ); + + // Wait for the images, and make sure they're cancelable while we do. + const manifestImagePromises = manifestImages.values(); + cancelableResourcePromises.push(...manifestImagePromises); + await Promise.all(manifestImagePromises); + + // Finally, once we have the images loaded, the library object expects us to + // mutate it (!) to give it the actual image and sprite sheet objects from + // the loaded images. That's how the MovieClip's internal JS objects will + // access the loaded data! + const images = composition.getImages(); + for (const [id, image] of manifestImages.entries()) { + images[id] = await image; + } + const spriteSheets = composition.getSpriteSheet(); + for (const { name, frames } of library.ssMetadata) { + const image = await manifestImages.get(name); + spriteSheets[name] = new window.createjs.SpriteSheet({ + images: [image], + frames, + }); + } + + MOVIE_LIBRARY_CACHE.set(librarySrc, library); + + return library; + }; + + const movieLibraryPromise = createMovieLibraryPromise().catch((e) => { + // When any part of the movie library fails, we also cancel the other + // resources ourselves, to avoid stray throws for resources that fail after + // the parent catches the initial failure. We re-throw the initial failure + // for the parent to handle, though! + cancelAllResources(); + throw e; + }); + + // To cancel a `loadMovieLibrary`, cancel all of the resource promises we + // load as part of it. That should effectively halt the async function above + // (anything not yet loaded will stop loading), and ensure that stray + // failures don't trigger uncaught promise rejection warnings. + movieLibraryPromise.cancel = cancelAllResources; + + return movieLibraryPromise; +} + +export function buildMovieClip(library, libraryUrl) { + let constructorName; + try { + const fileName = decodeURI(libraryUrl).split("/").pop(); + const fileNameWithoutExtension = fileName.split(".")[0]; + constructorName = fileNameWithoutExtension.replace(/[ -]/g, ""); + if (constructorName.match(/^[0-9]/)) { + constructorName = "_" + constructorName; + } + } catch (e) { + throw new Error( + `Movie libraryUrl ${JSON.stringify( + libraryUrl + )} did not match expected format: ${e.message}` + ); + } + + const LibraryMovieClipConstructor = library[constructorName]; + if (!LibraryMovieClipConstructor) { + throw new Error( + `Expected JS movie library ${libraryUrl} to contain a constructor ` + + `named ${constructorName}, but it did not: ${Object.keys(library)}` + ); + } + const movieClip = new LibraryMovieClipConstructor(); + + return movieClip; +} + +/** + * Recursively scans the given MovieClip (or child createjs node), to see if + * there are any animated areas. + */ +export function hasAnimations(createjsNode) { + return ( + // Some nodes have simple animation frames. + createjsNode.totalFrames > 1 || + // Tweens are a form of animation that can happen separately from frames. + // They expect timer ticks to happen, and they change the scene accordingly. + createjsNode?.timeline?.tweens?.length >= 1 || + // And some nodes have _children_ that are animated. + (createjsNode.children || []).some(hasAnimations) + ); +} + +export default OutfitMovieLayer; diff --git a/app/javascript/wardrobe-2020/components/OutfitPreview.js b/app/javascript/wardrobe-2020/components/OutfitPreview.js new file mode 100644 index 00000000..f024ff8f --- /dev/null +++ b/app/javascript/wardrobe-2020/components/OutfitPreview.js @@ -0,0 +1,541 @@ +import React from "react"; +import { + Box, + DarkMode, + Flex, + Text, + useColorModeValue, + useToast, +} from "@chakra-ui/react"; +import LRU from "lru-cache"; +import { WarningIcon } from "@chakra-ui/icons"; +import { ClassNames } from "@emotion/react"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; + +import OutfitMovieLayer, { + buildMovieClip, + hasAnimations, + loadMovieLibrary, +} from "./OutfitMovieLayer"; +import HangerSpinner from "./HangerSpinner"; +import { loadImage, safeImageUrl, useLocalStorage } from "../util"; +import useOutfitAppearance from "./useOutfitAppearance"; +import usePreferArchive from "./usePreferArchive"; + +/** + * OutfitPreview is for rendering a full outfit! It accepts outfit data, + * fetches the appearance data for it, and preloads and renders the layers + * together. + * + * If the species/color/pose fields are null and a `placeholder` node is + * provided instead, we'll render the placeholder. And then, once those props + * become non-null, we'll keep showing the placeholder below the loading + * overlay until loading completes. (We use this on the homepage to show the + * beach splash until outfit data arrives!) + * + * TODO: There's some duplicate work happening in useOutfitAppearance and + * useOutfitState both getting appearance data on first load... + */ +function OutfitPreview(props) { + const { preview } = useOutfitPreview(props); + return preview; +} + +/** + * useOutfitPreview is like ``, but a bit more power! + * + * It takes the same props and returns a `preview` field, which is just like + * `` - but it also returns `appearance` data too, in case you + * want to show some additional UI that uses the appearance data we loaded! + */ +export function useOutfitPreview({ + speciesId, + colorId, + pose, + wornItemIds, + appearanceId = null, + isLoading = false, + placeholder = null, + loadingDelayMs, + spinnerVariant, + onChangeHasAnimations = null, + ...props +}) { + const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); + const toast = useToast(); + + const appearance = useOutfitAppearance({ + speciesId, + colorId, + pose, + appearanceId, + wornItemIds, + }); + const { loading, error, visibleLayers } = appearance; + + const { + loading: loading2, + error: error2, + loadedLayers, + layersHaveAnimations, + } = usePreloadLayers(visibleLayers); + + const onMovieError = React.useCallback(() => { + if (!toast.isActive("outfit-preview-on-movie-error")) { + toast({ + id: "outfit-preview-on-movie-error", + status: "warning", + title: "Oops, we couldn't load one of these animations.", + description: "We'll show a static image version instead.", + duration: null, + isClosable: true, + }); + } + }, [toast]); + + const onLowFps = React.useCallback( + (fps) => { + setIsPaused(true); + console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`); + + if (!toast.isActive("outfit-preview-on-low-fps")) { + toast({ + id: "outfit-preview-on-low-fps", + status: "warning", + title: "Sorry, the animation was lagging, so we paused it! 😖", + description: + "We do this to help make sure your machine doesn't lag too much! " + + "You can unpause the preview to try again.", + duration: null, + isClosable: true, + }); + } + }, + [setIsPaused, toast] + ); + + React.useEffect(() => { + if (onChangeHasAnimations) { + onChangeHasAnimations(layersHaveAnimations); + } + }, [layersHaveAnimations, onChangeHasAnimations]); + + const textColor = useColorModeValue("green.700", "white"); + + let preview; + if (error || error2) { + preview = ( + + + + + Could not load preview. Try again? + + + ); + } else { + preview = ( + + ); + } + + return { appearance, preview }; +} + +/** + * OutfitLayers is the raw UI component for rendering outfit layers. It's + * used both in the main outfit preview, and in other minor UIs! + */ +export function OutfitLayers({ + loading, + visibleLayers, + placeholder = null, + loadingDelayMs = 500, + spinnerVariant = "overlay", + doTransitions = false, + isPaused = true, + onMovieError = null, + onLowFps = null, + ...props +}) { + const [hiResMode] = useLocalStorage("DTIHiResMode", false); + const [preferArchive] = usePreferArchive(); + + const containerRef = React.useRef(null); + const [canvasSize, setCanvasSize] = React.useState(0); + const [loadingDelayHasPassed, setLoadingDelayHasPassed] = + React.useState(false); + + // When we start in a loading state, or re-enter a loading state, start the + // loading delay timer. + React.useEffect(() => { + if (loading) { + setLoadingDelayHasPassed(false); + const t = setTimeout( + () => setLoadingDelayHasPassed(true), + loadingDelayMs + ); + return () => clearTimeout(t); + } + }, [loadingDelayMs, loading]); + + React.useLayoutEffect(() => { + function computeAndSaveCanvasSize() { + setCanvasSize( + // Follow an algorithm similar to the sizing: a square that + // covers the available space, without exceeding the natural image size + // (which is 600px). + // + // TODO: Once we're entirely off PNGs, we could drop the 600 + // requirement, and let SVGs and movies scale up as far as they + // want... + Math.min( + containerRef.current.offsetWidth, + containerRef.current.offsetHeight, + 600 + ) + ); + } + + computeAndSaveCanvasSize(); + window.addEventListener("resize", computeAndSaveCanvasSize); + return () => window.removeEventListener("resize", computeAndSaveCanvasSize); + }, [setCanvasSize]); + + return ( + + {({ css }) => ( + + {placeholder && ( + + + {placeholder} + + + )} + + {visibleLayers.map((layer) => ( + + + {layer.canvasMovieLibraryUrl ? ( + + ) : ( + + )} + + + ))} + + + {spinnerVariant === "overlay" && ( + <> + + {/* Against the dark overlay, use the Dark Mode spinner. */} + + + + + )} + {spinnerVariant === "corner" && ( + + )} + + + )} + + ); +} + +export function FullScreenCenter({ children, ...otherProps }) { + return ( + + {children} + + ); +} + +export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) { + if (hiResMode && layer.svgUrl) { + return layer.svgUrl; + } else { + return layer.imageUrl; + } +} + +/** + * usePreloadLayers preloads the images for the given layers, and yields them + * when done. This enables us to keep the old outfit preview on screen until + * all the new layers are ready, then show them all at once! + */ +export function usePreloadLayers(layers) { + const [hiResMode] = useLocalStorage("DTIHiResMode", false); + const [preferArchive] = usePreferArchive(); + + const [error, setError] = React.useState(null); + const [loadedLayers, setLoadedLayers] = React.useState([]); + const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false); + + // NOTE: This condition would need to change if we started loading one at a + // time, or if the error case would need to show a partial state! + const loading = layers.length > 0 && loadedLayers !== layers; + + React.useEffect(() => { + // HACK: Don't clear the preview when we have zero layers, because it + // usually means the parent is still loading data. I feel like this isn't + // the right abstraction, though... + if (layers.length === 0) { + return; + } + + let canceled = false; + setError(null); + setLayersHaveAnimations(false); + + const minimalAssetPromises = []; + const imageAssetPromises = []; + const movieAssetPromises = []; + for (const layer of layers) { + const imageAssetPromise = loadImage( + getBestImageUrlForLayer(layer, { hiResMode }), + { preferArchive } + ); + imageAssetPromises.push(imageAssetPromise); + + if (layer.canvasMovieLibraryUrl) { + // Start preloading the movie. But we won't block on it! The blocking + // request will still be the image, which we'll show as a + // placeholder, which should usually be noticeably faster! + const movieLibraryPromise = loadMovieLibrary( + layer.canvasMovieLibraryUrl, + { preferArchive } + ); + const movieAssetPromise = movieLibraryPromise.then((library) => ({ + library, + libraryUrl: layer.canvasMovieLibraryUrl, + })); + movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl; + movieAssetPromise.cancel = () => movieLibraryPromise.cancel(); + movieAssetPromises.push(movieAssetPromise); + + // The minimal asset for the movie case is *either* the image *or* + // the movie, because we can start rendering when either is ready. + minimalAssetPromises.push( + Promise.any([imageAssetPromise, movieAssetPromise]) + ); + } else { + minimalAssetPromises.push(imageAssetPromise); + } + } + + // When the minimal assets have loaded, we can say the layers have + // loaded, and allow the UI to start showing them! + Promise.all(minimalAssetPromises) + .then(() => { + if (canceled) return; + setLoadedLayers(layers); + }) + .catch((e) => { + if (canceled) return; + console.error("Error preloading outfit layers", e); + setError(e); + + // Cancel any remaining promises, if cancelable. + imageAssetPromises.forEach((p) => p.cancel && p.cancel()); + movieAssetPromises.forEach((p) => p.cancel && p.cancel()); + }); + + // As the movie assets come in, check them for animations, to decide + // whether to show the Play/Pause button. + const checkHasAnimations = (asset) => { + if (canceled) return; + let assetHasAnimations; + try { + assetHasAnimations = getHasAnimationsForMovieAsset(asset); + } catch (e) { + console.error("Error testing layers for animations", e); + setError(e); + return; + } + + setLayersHaveAnimations( + (alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations + ); + }; + movieAssetPromises.forEach((p) => + p.then(checkHasAnimations).catch((e) => { + console.error(`Error preloading movie library ${p.libraryUrl}:`, e); + }) + ); + + return () => { + canceled = true; + }; + }, [layers, hiResMode, preferArchive]); + + return { loading, error, loadedLayers, layersHaveAnimations }; +} + +// This cache is large because it's only storing booleans; mostly just capping +// it to put *some* upper bound on memory growth. +const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50); + +function getHasAnimationsForMovieAsset({ library, libraryUrl }) { + // This operation can be pretty expensive! We store a cache to only do it + // once per layer per session ish, instead of on each outfit change. + const cachedHasAnimations = + HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl); + if (cachedHasAnimations) { + return cachedHasAnimations; + } + + const movieClip = buildMovieClip(library, libraryUrl); + + // Some movie clips require you to tick to the first frame of the movie + // before the children mount onto the stage. If we detect animations + // without doing this, we'll incorrectly say no, because we see no children! + // Example: http://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js + movieClip.advance(); + + const movieClipHasAnimations = hasAnimations(movieClip); + + HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations); + return movieClipHasAnimations; +} + +/** + * FadeInOnLoad attaches an `onLoad` handler to its single child, and fades in + * the container element once it triggers. + */ +function FadeInOnLoad({ children, ...props }) { + const [isLoaded, setIsLoaded] = React.useState(false); + + const onLoad = React.useCallback(() => setIsLoaded(true), []); + + const child = React.Children.only(children); + const wrappedChild = React.cloneElement(child, { onLoad }); + + return ( + + {wrappedChild} + + ); +} + +// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any +// NOTE: Normally I would've considered Promise.any within our support browser +// range… but it's affected 25 users in the past two months, which is +// surprisingly high. And the polyfill is small, so let's do it! (11/2021) +Promise.any = + Promise.any || + function ($) { + return new Promise(function (D, E, A, L) { + A = []; + L = $.map(function ($, i) { + return Promise.resolve($).then(D, function (O) { + return ((A[i] = O), --L) || E({ errors: A }); + }); + }).length; + }); + }; + +export default OutfitPreview; diff --git a/app/javascript/wardrobe-2020/components/OutfitThumbnail.js b/app/javascript/wardrobe-2020/components/OutfitThumbnail.js new file mode 100644 index 00000000..f9e8d0c6 --- /dev/null +++ b/app/javascript/wardrobe-2020/components/OutfitThumbnail.js @@ -0,0 +1,21 @@ +import { Box } from "@chakra-ui/react"; + +function OutfitThumbnail({ outfitId, updatedAt, ...props }) { + const versionTimestamp = new Date(updatedAt).getTime(); + + // NOTE: It'd be more reliable for testing to use a relative path, but + // generating these on dev is SO SLOW, that I'd rather just not. + const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`; + const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`; + + return ( + + ); +} + +export default OutfitThumbnail; diff --git a/app/javascript/wardrobe-2020/components/PaginationToolbar.js b/app/javascript/wardrobe-2020/components/PaginationToolbar.js new file mode 100644 index 00000000..2ba0b47a --- /dev/null +++ b/app/javascript/wardrobe-2020/components/PaginationToolbar.js @@ -0,0 +1,153 @@ +import React from "react"; +import { Box, Button, Flex, Select } from "@chakra-ui/react"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +function PaginationToolbar({ + isLoading, + numTotalPages, + currentPageNumber, + goToPageNumber, + buildPageUrl, + size = "md", + ...props +}) { + const pagesAreLoaded = currentPageNumber != null && numTotalPages != null; + const hasPrevPage = pagesAreLoaded && currentPageNumber > 1; + const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages; + + const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null; + const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null; + + return ( + + goToPageNumber(currentPageNumber - 1) + : undefined + } + _disabled={{ + cursor: isLoading ? "wait" : "not-allowed", + opacity: 0.4, + }} + isDisabled={!hasPrevPage} + size={size} + > + ← Prev + + {numTotalPages > 0 && ( + + Page + + + + of {numTotalPages} + + )} + goToPageNumber(currentPageNumber + 1) + : undefined + } + _disabled={{ + cursor: isLoading ? "wait" : "not-allowed", + opacity: 0.4, + }} + isDisabled={!hasNextPage} + size={size} + > + Next → + + + ); +} + +export function useRouterPagination(totalCount, numPerPage) { + const { query, push: pushHistory } = useRouter(); + + const currentOffset = parseInt(query.offset) || 0; + + const currentPageIndex = Math.floor(currentOffset / numPerPage); + const currentPageNumber = currentPageIndex + 1; + const numTotalPages = totalCount ? Math.ceil(totalCount / numPerPage) : null; + + const buildPageUrl = React.useCallback( + (newPageNumber) => { + const newParams = new URLSearchParams(query); + const newPageIndex = newPageNumber - 1; + const newOffset = newPageIndex * numPerPage; + newParams.set("offset", newOffset); + return "?" + newParams.toString(); + }, + [query, numPerPage] + ); + + const goToPageNumber = React.useCallback( + (newPageNumber) => { + pushHistory(buildPageUrl(newPageNumber)); + }, + [buildPageUrl, pushHistory] + ); + + return { + numTotalPages, + currentPageNumber, + goToPageNumber, + buildPageUrl, + }; +} + +function LinkOrButton({ href, ...props }) { + if (href != null) { + return ( + +