forked from OpenNeo/impress
Emi Matchu
0e314482f7
I haven't been running Prettier consistently on things in this project. Now, it's quick-runnable, and I've got it on everything! Also, I just think tabs are the right default for this kind of thing, and I'm glad to get to switch over to it! (In `package.json`.)
453 lines
11 KiB
JavaScript
453 lines
11 KiB
JavaScript
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;
|