2020-09-04 05:59:35 -07:00
|
|
|
import React from "react";
|
2020-10-28 00:00:14 -07:00
|
|
|
import { css } from "emotion";
|
2020-10-27 23:09:42 -07:00
|
|
|
import { Badge, Box, Center, Wrap, VStack } from "@chakra-ui/core";
|
2020-10-24 01:06:55 -07:00
|
|
|
import { CheckIcon, EmailIcon, StarIcon } from "@chakra-ui/icons";
|
2020-09-04 05:59:35 -07:00
|
|
|
import gql from "graphql-tag";
|
|
|
|
import { useParams } from "react-router-dom";
|
|
|
|
import { useQuery } from "@apollo/client";
|
2020-10-28 00:00:14 -07:00
|
|
|
import SimpleMarkdown from "simple-markdown";
|
|
|
|
import DOMPurify from "dompurify";
|
2020-09-04 05:59:35 -07:00
|
|
|
|
|
|
|
import HangerSpinner from "./components/HangerSpinner";
|
2020-10-27 23:09:42 -07:00
|
|
|
import { Heading1, Heading2, Heading3 } from "./util";
|
2020-09-11 20:41:39 -07:00
|
|
|
import ItemCard, {
|
|
|
|
ItemBadgeList,
|
2020-09-11 21:53:57 -07:00
|
|
|
ItemCardList,
|
2020-09-11 20:41:39 -07:00
|
|
|
NcBadge,
|
|
|
|
NpBadge,
|
2020-09-11 21:45:38 -07:00
|
|
|
YouOwnThisBadge,
|
|
|
|
YouWantThisBadge,
|
2020-11-03 20:11:37 -08:00
|
|
|
getZoneBadges,
|
2020-09-11 20:41:39 -07:00
|
|
|
} from "./components/ItemCard";
|
2020-09-04 05:59:35 -07:00
|
|
|
import useCurrentUser from "./components/useCurrentUser";
|
2020-10-10 02:07:49 -07:00
|
|
|
import WIPCallout from "./components/WIPCallout";
|
2020-09-04 05:59:35 -07:00
|
|
|
|
2020-09-11 23:20:06 -07:00
|
|
|
function UserItemsPage() {
|
2020-09-04 05:59:35 -07:00
|
|
|
const { userId } = useParams();
|
|
|
|
const currentUser = useCurrentUser();
|
|
|
|
const isCurrentUser = currentUser.id === userId;
|
|
|
|
|
|
|
|
const { loading, error, data } = useQuery(
|
|
|
|
gql`
|
|
|
|
query ItemsPage($userId: ID!) {
|
|
|
|
user(id: $userId) {
|
|
|
|
id
|
|
|
|
username
|
2020-10-23 23:00:53 -07:00
|
|
|
contactNeopetsUsername
|
2020-09-11 21:34:28 -07:00
|
|
|
|
2020-10-27 23:09:42 -07:00
|
|
|
closetLists {
|
2020-09-04 05:59:35 -07:00
|
|
|
id
|
|
|
|
name
|
2020-10-28 00:00:14 -07:00
|
|
|
description
|
2020-10-27 23:09:42 -07:00
|
|
|
ownsOrWantsItems
|
|
|
|
isDefaultList
|
|
|
|
items {
|
2020-10-23 23:29:50 -07:00
|
|
|
id
|
2020-10-27 23:09:42 -07:00
|
|
|
isNc
|
|
|
|
name
|
|
|
|
thumbnailUrl
|
|
|
|
currentUserOwnsThis
|
|
|
|
currentUserWantsThis
|
|
|
|
allOccupiedZones {
|
|
|
|
id
|
|
|
|
label @client
|
|
|
|
}
|
2020-10-23 23:29:50 -07:00
|
|
|
}
|
2020-09-11 21:34:28 -07:00
|
|
|
}
|
2020-09-04 05:59:35 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
{ variables: { userId } }
|
|
|
|
);
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
return (
|
2020-09-11 23:27:23 -07:00
|
|
|
<Center>
|
2020-09-10 03:06:44 -07:00
|
|
|
<HangerSpinner />
|
2020-09-11 23:27:23 -07:00
|
|
|
</Center>
|
2020-09-04 05:59:35 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (error) {
|
2020-09-06 18:12:34 -07:00
|
|
|
return <Box color="red.400">{error.message}</Box>;
|
2020-09-04 05:59:35 -07:00
|
|
|
}
|
|
|
|
|
2020-10-27 23:09:42 -07:00
|
|
|
if (data.user == null) {
|
|
|
|
return <Box color="red.400">User not found</Box>;
|
|
|
|
}
|
|
|
|
|
|
|
|
const listsOfOwnedItems = data.user.closetLists.filter(
|
|
|
|
(l) => l.ownsOrWantsItems === "OWNS"
|
|
|
|
);
|
|
|
|
const listsOfWantedItems = data.user.closetLists.filter(
|
|
|
|
(l) => l.ownsOrWantsItems === "WANTS"
|
|
|
|
);
|
|
|
|
|
|
|
|
// Sort default list to the end, then sort alphabetically. We use a similar
|
|
|
|
// sort hack that we use for sorting items in ClosetList!
|
|
|
|
listsOfOwnedItems.sort((a, b) => {
|
|
|
|
const aName = `${a.isDefaultList ? "ZZZ" : "AAA"} ${a.name}`;
|
|
|
|
const bName = `${b.isDefaultList ? "ZZZ" : "AAA"} ${b.name}`;
|
2020-09-11 22:04:37 -07:00
|
|
|
return aName.localeCompare(bName);
|
|
|
|
});
|
2020-10-27 23:09:42 -07:00
|
|
|
listsOfWantedItems.sort((a, b) => {
|
|
|
|
const aName = `${a.isDefaultList ? "ZZZ" : "AAA"} ${a.name}`;
|
|
|
|
const bName = `${b.isDefaultList ? "ZZZ" : "AAA"} ${b.name}`;
|
2020-09-11 22:04:37 -07:00
|
|
|
return aName.localeCompare(bName);
|
|
|
|
});
|
|
|
|
|
2020-10-27 23:09:42 -07:00
|
|
|
const allItemsTheyOwn = listsOfOwnedItems.map((l) => l.items).flat();
|
|
|
|
const allItemsTheyWant = listsOfWantedItems.map((l) => l.items).flat();
|
|
|
|
|
|
|
|
const itemsTheyOwnThatYouWant = allItemsTheyOwn.filter(
|
|
|
|
(i) => i.currentUserWantsThis
|
|
|
|
);
|
|
|
|
const itemsTheyWantThatYouOwn = allItemsTheyWant.filter(
|
|
|
|
(i) => i.currentUserOwnsThis
|
|
|
|
);
|
|
|
|
|
|
|
|
// It's important to de-duplicate these! Otherwise, if the same item appears
|
|
|
|
// in multiple lists, we'll double-count it.
|
|
|
|
const numItemsTheyOwnThatYouWant = new Set(
|
|
|
|
itemsTheyOwnThatYouWant.map((i) => i.id)
|
|
|
|
).size;
|
|
|
|
const numItemsTheyWantThatYouOwn = new Set(
|
|
|
|
itemsTheyWantThatYouOwn.map((i) => i.id)
|
|
|
|
).size;
|
|
|
|
|
2020-09-04 05:59:35 -07:00
|
|
|
return (
|
2020-09-06 18:45:20 -07:00
|
|
|
<Box>
|
2020-11-03 14:45:43 -08:00
|
|
|
{isCurrentUser && (
|
|
|
|
<Box float="right">
|
|
|
|
<WIPCallout details="These lists are read-only for now. To edit, head back to Classic DTI!" />
|
|
|
|
</Box>
|
|
|
|
)}
|
2020-10-24 00:08:22 -07:00
|
|
|
<Heading1>
|
2020-10-23 23:00:53 -07:00
|
|
|
{isCurrentUser ? "Your items" : `${data.user.username}'s items`}
|
2020-09-04 05:59:35 -07:00
|
|
|
</Heading1>
|
2020-10-24 01:06:55 -07:00
|
|
|
<Wrap spacing="2" opacity="0.7">
|
|
|
|
{data.user.contactNeopetsUsername && (
|
2020-10-24 00:08:22 -07:00
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href={`http://www.neopets.com/userlookup.phtml?user=${data.user.contactNeopetsUsername}`}
|
2020-10-24 01:06:55 -07:00
|
|
|
display="flex"
|
2020-10-24 00:08:22 -07:00
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<NeopetsStarIcon marginRight="1" />
|
|
|
|
{data.user.contactNeopetsUsername}
|
|
|
|
</Badge>
|
2020-10-24 01:06:55 -07:00
|
|
|
)}
|
|
|
|
{data.user.contactNeopetsUsername && (
|
2020-10-24 00:08:22 -07:00
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${data.user.contactNeopetsUsername}`}
|
2020-10-24 01:06:55 -07:00
|
|
|
display="flex"
|
2020-10-24 00:08:22 -07:00
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<EmailIcon marginRight="1" />
|
|
|
|
Neomail
|
|
|
|
</Badge>
|
2020-10-24 01:06:55 -07:00
|
|
|
)}
|
2020-10-27 21:43:57 -07:00
|
|
|
{/* Usually I put "Own" before "Want", but this matches the natural
|
|
|
|
* order on the page: the _matches_ for things you want are things
|
|
|
|
* _this user_ owns, so they come first. I think it's also probably a
|
|
|
|
* more natural train of thought: you come to someone's list _wanting_
|
|
|
|
* something, and _then_ thinking about what you can offer. */}
|
2020-10-27 23:09:42 -07:00
|
|
|
{!isCurrentUser && numItemsTheyOwnThatYouWant > 0 && (
|
2020-10-24 01:06:55 -07:00
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href="#owned-items"
|
|
|
|
colorScheme="blue"
|
|
|
|
display="flex"
|
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<StarIcon marginRight="1" />
|
2020-10-27 23:09:42 -07:00
|
|
|
{numItemsTheyOwnThatYouWant > 1
|
|
|
|
? `${numItemsTheyOwnThatYouWant} items you want`
|
2020-10-24 01:06:55 -07:00
|
|
|
: "1 item you want"}
|
|
|
|
</Badge>
|
|
|
|
)}
|
2020-10-27 23:09:42 -07:00
|
|
|
{!isCurrentUser && numItemsTheyWantThatYouOwn > 0 && (
|
2020-10-27 21:43:57 -07:00
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href="#wanted-items"
|
|
|
|
colorScheme="green"
|
|
|
|
display="flex"
|
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<CheckIcon marginRight="1" />
|
2020-10-27 23:09:42 -07:00
|
|
|
{numItemsTheyWantThatYouOwn > 1
|
|
|
|
? `${numItemsTheyWantThatYouOwn} items you own`
|
2020-10-27 21:43:57 -07:00
|
|
|
: "1 item you own"}
|
|
|
|
</Badge>
|
|
|
|
)}
|
2020-10-24 01:06:55 -07:00
|
|
|
</Wrap>
|
2020-10-27 23:09:42 -07:00
|
|
|
<Heading2 id="owned-items" marginTop="4" marginBottom="2">
|
2020-10-23 23:00:53 -07:00
|
|
|
{isCurrentUser ? "Items you own" : `Items ${data.user.username} owns`}
|
|
|
|
</Heading2>
|
2020-10-27 23:09:42 -07:00
|
|
|
<VStack spacing="8" alignItems="stretch">
|
|
|
|
{listsOfOwnedItems.map((closetList) => (
|
|
|
|
<ClosetList
|
|
|
|
key={closetList.id}
|
|
|
|
closetList={closetList}
|
|
|
|
isCurrentUser={isCurrentUser}
|
|
|
|
showHeading={listsOfOwnedItems.length > 1}
|
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</VStack>
|
|
|
|
|
|
|
|
<Heading2 id="wanted-items" marginTop="10" marginBottom="2">
|
|
|
|
{isCurrentUser ? "Items you want" : `Items ${data.user.username} wants`}
|
|
|
|
</Heading2>
|
|
|
|
<VStack spacing="4" alignItems="stretch">
|
|
|
|
{listsOfWantedItems.map((closetList) => (
|
|
|
|
<ClosetList
|
|
|
|
key={closetList.id}
|
|
|
|
closetList={closetList}
|
|
|
|
isCurrentUser={isCurrentUser}
|
|
|
|
showHeading={listsOfWantedItems.length > 1}
|
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</VStack>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
|
|
|
const hasYouWantThisBadge = (item) =>
|
|
|
|
!isCurrentUser &&
|
|
|
|
closetList.ownsOrWantsItems === "OWNS" &&
|
|
|
|
item.currentUserWantsThis;
|
|
|
|
const hasYouOwnThisBadge = (item) =>
|
|
|
|
!isCurrentUser &&
|
|
|
|
closetList.ownsOrWantsItems === "WANTS" &&
|
|
|
|
item.currentUserOwnsThis;
|
|
|
|
const hasAnyTradeBadge = (item) =>
|
|
|
|
hasYouOwnThisBadge(item) || hasYouWantThisBadge(item);
|
|
|
|
|
|
|
|
const sortedItems = [...closetList.items].sort((a, b) => {
|
|
|
|
// This is a cute sort hack. We sort first by, bringing "You own/want
|
|
|
|
// this!" to the top, and then sorting by name _within_ those two groups.
|
|
|
|
const aName = `${hasAnyTradeBadge(a) ? "000" : "999"} ${a.name}`;
|
|
|
|
const bName = `${hasAnyTradeBadge(b) ? "000" : "999"} ${b.name}`;
|
|
|
|
return aName.localeCompare(bName);
|
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box>
|
|
|
|
{showHeading && (
|
|
|
|
<Heading3
|
|
|
|
marginBottom="2"
|
|
|
|
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
|
|
|
>
|
|
|
|
{closetList.name}
|
|
|
|
</Heading3>
|
|
|
|
)}
|
2020-10-28 00:00:14 -07:00
|
|
|
{closetList.description && (
|
|
|
|
<Box marginBottom="2">
|
|
|
|
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
|
|
|
</Box>
|
|
|
|
)}
|
2020-10-27 23:09:42 -07:00
|
|
|
{sortedItems.length > 0 ? (
|
|
|
|
<ItemCardList>
|
|
|
|
{sortedItems.map((item) => (
|
2020-10-23 23:29:50 -07:00
|
|
|
<ItemCard
|
|
|
|
key={item.id}
|
|
|
|
item={item}
|
|
|
|
badges={
|
|
|
|
<ItemBadgeList>
|
|
|
|
{item.isNc ? <NcBadge /> : <NpBadge />}
|
2020-10-27 23:09:42 -07:00
|
|
|
{hasYouOwnThisBadge(item) && <YouOwnThisBadge />}
|
|
|
|
{hasYouWantThisBadge(item) && <YouWantThisBadge />}
|
2020-11-03 20:11:37 -08:00
|
|
|
{getZoneBadges(item.allOccupiedZones, {
|
|
|
|
variant: "occupies",
|
|
|
|
})}
|
2020-10-23 23:29:50 -07:00
|
|
|
</ItemBadgeList>
|
|
|
|
}
|
|
|
|
/>
|
2020-10-27 23:09:42 -07:00
|
|
|
))}
|
|
|
|
</ItemCardList>
|
|
|
|
) : (
|
|
|
|
<Box fontStyle="italic">This list is empty!</Box>
|
|
|
|
)}
|
2020-09-04 05:59:35 -07:00
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-10-28 00:00:14 -07:00
|
|
|
const unsafeMarkdownRules = {
|
|
|
|
autolink: SimpleMarkdown.defaultRules.autolink,
|
|
|
|
br: SimpleMarkdown.defaultRules.br,
|
|
|
|
em: SimpleMarkdown.defaultRules.em,
|
|
|
|
escape: SimpleMarkdown.defaultRules.escape,
|
|
|
|
link: SimpleMarkdown.defaultRules.link,
|
|
|
|
list: SimpleMarkdown.defaultRules.list,
|
|
|
|
newline: SimpleMarkdown.defaultRules.newline,
|
|
|
|
paragraph: SimpleMarkdown.defaultRules.paragraph,
|
|
|
|
strong: SimpleMarkdown.defaultRules.strong,
|
|
|
|
u: SimpleMarkdown.defaultRules.u,
|
|
|
|
|
|
|
|
// DANGER: We override Markdown's `text` rule to _not_ escape HTML. This is
|
|
|
|
// intentional, to allow users to embed some limited HTML. DOMPurify is
|
|
|
|
// responsible for sanitizing the HTML afterward. Do not use these rules
|
|
|
|
// without sanitizing!!
|
|
|
|
text: {
|
|
|
|
...SimpleMarkdown.defaultRules.text,
|
|
|
|
html: (node) => node.content,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
const markdownParser = SimpleMarkdown.parserFor(unsafeMarkdownRules);
|
|
|
|
const unsafeMarkdownOutput = SimpleMarkdown.htmlFor(
|
|
|
|
SimpleMarkdown.ruleOutput(unsafeMarkdownRules, "html")
|
|
|
|
);
|
|
|
|
|
|
|
|
function MarkdownAndSafeHTML({ children }) {
|
|
|
|
const htmlAndMarkdown = children;
|
|
|
|
|
|
|
|
const unsafeHtml = unsafeMarkdownOutput(markdownParser(htmlAndMarkdown));
|
|
|
|
|
|
|
|
const sanitizedHtml = DOMPurify.sanitize(unsafeHtml, {
|
|
|
|
ALLOWED_TAGS: [
|
|
|
|
"b",
|
|
|
|
"i",
|
|
|
|
"u",
|
|
|
|
"strong",
|
|
|
|
"em",
|
|
|
|
"a",
|
|
|
|
"p",
|
|
|
|
"div",
|
|
|
|
"br",
|
|
|
|
"ol",
|
|
|
|
"ul",
|
|
|
|
"li",
|
|
|
|
],
|
|
|
|
ALLOWED_ATTR: ["href", "class"],
|
|
|
|
// URL must either start with an approved host (external link), or with a
|
|
|
|
// slash or hash (internal link).
|
2020-11-08 14:35:02 -08:00
|
|
|
ALLOWED_URI_REGEXP: /^https?:\/\/(impress\.openneo\.net|impress-2020\.openneo\.net|www\.neopets\.com|neopets\.com)\/|^[/#]/,
|
2020-10-28 00:00:14 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box
|
|
|
|
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
|
|
|
className={css`
|
|
|
|
.paragraph,
|
|
|
|
ol,
|
|
|
|
ul {
|
|
|
|
margin-bottom: 1em;
|
|
|
|
}
|
|
|
|
|
|
|
|
ol,
|
|
|
|
ul {
|
|
|
|
margin-left: 2em;
|
|
|
|
}
|
|
|
|
`}
|
|
|
|
></Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-10-24 00:08:22 -07:00
|
|
|
function NeopetsStarIcon(props) {
|
2020-10-24 00:14:42 -07:00
|
|
|
// Converted from the Neopets favicon with https://www.vectorizer.io/.
|
2020-10-24 00:08:22 -07:00
|
|
|
return (
|
|
|
|
<Box {...props}>
|
|
|
|
<svg
|
|
|
|
version="1.0"
|
|
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
|
|
width="1.2em"
|
|
|
|
height="1.2em"
|
|
|
|
viewBox="0 0 160 160"
|
|
|
|
preserveAspectRatio="xMidYMid meet"
|
|
|
|
>
|
|
|
|
<path
|
|
|
|
fill="currentColor"
|
|
|
|
fill-rule="evenodd"
|
|
|
|
d="M85 129 L60 108 40 119 C11 135,7 132,24 108 L39 86 23 68 L6 50 32 50 L58 50 73 29 L88 8 94 29 L101 50 128 50 L155 50 131 68 L107 86 113 118 C121 155,118 156,85 129 "
|
|
|
|
/>
|
|
|
|
</svg>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-09-11 23:20:06 -07:00
|
|
|
export default UserItemsPage;
|