import React from "react"; import { Box, IconButton, Skeleton, useColorModeValue, useTheme, useToken, } from "@chakra-ui/react"; import { ClassNames } from "@emotion/react"; import { safeImageUrl, useCommonStyles } from "../util"; import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons"; import usePreferArchive from "./usePreferArchive"; function SquareItemCard({ item, showRemoveButton = false, onRemove = () => {}, tradeMatchingMode = null, footer = null, ...props }) { const outlineShadowValue = useToken("shadows", "outline"); const mdRadiusValue = useToken("radii", "md"); const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200"); const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200"); const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] = useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]); // When this is a trade match, give it an extra colorful shadow highlight so // it stands out! (They'll generally be sorted to the front anyway, but this // make it easier to scan a user's lists page, and to learn how the sorting // works!) let tradeMatchShadow; if (tradeMatchingMode === "offering" && item.currentUserWantsThis) { tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`; } else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) { tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`; } else { tradeMatchShadow = null; } return ( <ClassNames> {({ css }) => ( // SquareItemCard renders in large lists of 1k+ items, so we get a big // perf win by using Emotion directly instead of Chakra's styled-system // Box. <div className={css` position: relative; display: flex; `} role="group" > <Box as="a" href={`/items/${item.id}`} className={css` border-radius: ${mdRadiusValue}; transition: all 0.2s; &:hover, &:focus { transform: scale(1.05); } &:focus { box-shadow: ${outlineShadowValue}; outline: none; } `} {...props} > <SquareItemCardLayout name={item.name} thumbnailImage={ <ItemThumbnail item={item} tradeMatchingMode={tradeMatchingMode} /> } removeButton={ showRemoveButton ? ( <SquareItemCardRemoveButton onClick={onRemove} /> ) : null } boxShadow={tradeMatchShadow} footer={footer} /> </Box> {showRemoveButton && ( <div className={css` position: absolute; right: 0; top: 0; transform: translate(50%, -50%); z-index: 1; /* Apply some padding, so accidental clicks around the button * don't click the link instead, or vice-versa! */ padding: 0.75em; opacity: 0; [role="group"]:hover &, [role="group"]:focus-within &, &:hover, &:focus-within { opacity: 1; } `} > <SquareItemCardRemoveButton onClick={onRemove} /> </div> )} </div> )} </ClassNames> ); } function SquareItemCardLayout({ name, thumbnailImage, footer, minHeightNumLines = 2, boxShadow = null, }) { const { brightBackground } = useCommonStyles(); const brightBackgroundValue = useToken("colors", brightBackground); const theme = useTheme(); return ( // SquareItemCard renders in large lists of 1k+ items, so we get a big perf // win by using Emotion directly instead of Chakra's styled-system Box. <ClassNames> {({ css }) => ( <div className={css` display: flex; flex-direction: column; align-items: center; text-align: center; box-shadow: ${boxShadow || theme.shadows.md}; border-radius: ${theme.radii.md}; padding: ${theme.space["3"]}; width: calc(80px + 2em); background: ${brightBackgroundValue}; `} > {thumbnailImage} <div className={css` margin-top: ${theme.space["1"]}; font-size: ${theme.fontSizes.sm}; /* Set min height to match a 2-line item name, so the cards * in a row aren't toooo differently sized... */ min-height: ${minHeightNumLines * 1.5 + "em"}; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; width: 100%; `} // HACK: Emotion turns this into -webkit-display: -webkit-box? style={{ display: "-webkit-box" }} > {name} </div> {footer && ( <Box marginTop="2" width="100%"> {footer} </Box> )} </div> )} </ClassNames> ); } function ItemThumbnail({ item, tradeMatchingMode }) { const [preferArchive] = usePreferArchive(); const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray"; const thumbnailShadowColor = useColorModeValue( `${kindColorScheme}.200`, `${kindColorScheme}.600`, ); const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor); const mdRadiusValue = useToken("radii", "md"); // Normally, we just show the owns/wants badges depending on whether the // current user owns/wants it. But, in a trade list, we use trade-matching // mode instead: only show the badge if it represents a viable trade, and add // some extra flair to it, too! let showOwnsBadge; let showWantsBadge; let showTradeMatchFlair; if (tradeMatchingMode == null) { showOwnsBadge = item.currentUserOwnsThis; showWantsBadge = item.currentUserWantsThis; showTradeMatchFlair = false; } else if (tradeMatchingMode === "offering") { showOwnsBadge = false; showWantsBadge = item.currentUserWantsThis; showTradeMatchFlair = true; } else if (tradeMatchingMode === "seeking") { showOwnsBadge = item.currentUserOwnsThis; showWantsBadge = false; showTradeMatchFlair = true; } else if (tradeMatchingMode === "hide-all") { showOwnsBadge = false; showWantsBadge = false; showTradeMatchFlair = false; } else { throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`); } return ( <ClassNames> {({ css }) => ( <div className={css` position: relative; `} > <img src={safeImageUrl(item.thumbnailUrl, { preferArchive })} alt={`Thumbnail art for ${item.name}`} width={80} height={80} className={css` border-radius: ${mdRadiusValue}; box-shadow: 0 0 4px ${thumbnailShadowColorValue}; /* Don't let alt text flash in while loading */ &:-moz-loading { visibility: hidden; } `} loading="lazy" /> <div className={css` position: absolute; top: -6px; left: -6px; display: flex; flex-direction: column; gap: 2px; `} > {showOwnsBadge && ( <ItemOwnsWantsBadge colorScheme="green" label={ showTradeMatchFlair ? "You own this, and they want it!" : "You own this" } > <CheckIcon /> {showTradeMatchFlair && ( <div className={css` margin-left: 0.25em; margin-right: 0.125rem; `} > Match </div> )} </ItemOwnsWantsBadge> )} {showWantsBadge && ( <ItemOwnsWantsBadge colorScheme="blue" label={ showTradeMatchFlair ? "You want this, and they own it!" : "You want this" } > <StarIcon /> {showTradeMatchFlair && ( <div className={css` margin-left: 0.25em; margin-right: 0.125rem; `} > Match </div> )} </ItemOwnsWantsBadge> )} </div> {item.isNc != null && ( <div className={css` position: absolute; bottom: -6px; right: -3px; `} > <ItemThumbnailKindBadge colorScheme={kindColorScheme}> {item.isNc ? "NC" : item.isPb ? "PB" : "NP"} </ItemThumbnailKindBadge> </div> )} </div> )} </ClassNames> ); } function ItemOwnsWantsBadge({ colorScheme, children, label }) { const badgeBackground = useColorModeValue( `${colorScheme}.100`, `${colorScheme}.500`, ); const badgeColor = useColorModeValue( `${colorScheme}.500`, `${colorScheme}.100`, ); const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [ badgeBackground, badgeColor, ]); return ( <ClassNames> {({ css }) => ( <div aria-label={label} title={label} className={css` border-radius: 999px; height: 16px; min-width: 16px; font-size: 14px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 2px ${badgeBackgroundValue}; /* Decrease the padding: I don't want to hit the edges, but I want * to be a circle when possible! */ padding-left: 0.125rem; padding-right: 0.125rem; /* Copied from Chakra <Badge> */ white-space: nowrap; vertical-align: middle; text-transform: uppercase; font-size: 0.65rem; font-weight: 700; background: ${badgeBackgroundValue}; color: ${badgeColorValue}; `} > {children} </div> )} </ClassNames> ); } function ItemThumbnailKindBadge({ colorScheme, children }) { const badgeBackground = useColorModeValue( `${colorScheme}.100`, `${colorScheme}.500`, ); const badgeColor = useColorModeValue( `${colorScheme}.500`, `${colorScheme}.100`, ); const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [ badgeBackground, badgeColor, ]); return ( <ClassNames> {({ css }) => ( <div className={css` /* Copied from Chakra <Badge> */ white-space: nowrap; vertical-align: middle; padding-left: 0.25rem; padding-right: 0.25rem; text-transform: uppercase; font-size: 0.65rem; border-radius: 0.125rem; font-weight: 700; background: ${badgeBackgroundValue}; color: ${badgeColorValue}; `} > {children} </div> )} </ClassNames> ); } function SquareItemCardRemoveButton({ onClick }) { const backgroundColor = useColorModeValue("gray.200", "gray.500"); return ( <IconButton aria-label="Remove" title="Remove" icon={<CloseIcon />} size="xs" borderRadius="full" boxShadow="lg" backgroundColor={backgroundColor} onClick={onClick} _hover={{ // Override night mode's fade-out on hover opacity: 1, transform: "scale(1.15, 1.15)", }} _focus={{ transform: "scale(1.15, 1.15)", boxShadow: "outline", }} /> ); } export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) { return ( <SquareItemCardLayout name={ <> <Skeleton width="100%" height="1em" marginTop="2" /> {minHeightNumLines >= 3 && ( <Skeleton width="100%" height="1em" marginTop="2" /> )} </> } thumbnailImage={<Skeleton width="80px" height="80px" />} minHeightNumLines={minHeightNumLines} footer={footer} /> ); } export default SquareItemCard;