Did some stuff in here for parsing the default list ID too. We skipped that when making the new list index page, but now maybe you could reasonably link to the default list? 🤔 not sure it's a huge deal though
453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
import React from "react";
|
|
import {
|
|
Box,
|
|
IconButton,
|
|
Skeleton,
|
|
useColorModeValue,
|
|
useTheme,
|
|
useToken,
|
|
} from "@chakra-ui/react";
|
|
import { ClassNames } from "@emotion/react";
|
|
import { Link } from "react-router-dom";
|
|
|
|
import { safeImageUrl, useCommonStyles } from "../util";
|
|
import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
|
|
|
|
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"
|
|
>
|
|
<Link
|
|
to={`/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}
|
|
/>
|
|
</Link>
|
|
{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 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)}
|
|
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;
|