impress/app/javascript/wardrobe-2020/components/SquareItemCard.js

454 lines
11 KiB
JavaScript
Raw Permalink Normal View History

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;