2021-06-12 04:45:23 -07:00
|
|
|
import React from "react";
|
|
|
|
import {
|
2021-06-18 18:38:56 -07:00
|
|
|
Badge,
|
2021-06-12 04:45:23 -07:00
|
|
|
Box,
|
|
|
|
Breadcrumb,
|
|
|
|
BreadcrumbItem,
|
|
|
|
BreadcrumbLink,
|
2021-06-19 09:44:44 -07:00
|
|
|
Button,
|
2021-06-12 04:45:23 -07:00
|
|
|
Center,
|
|
|
|
Flex,
|
2021-06-19 09:44:44 -07:00
|
|
|
HStack,
|
|
|
|
Input,
|
|
|
|
Textarea,
|
|
|
|
useToast,
|
2021-06-12 04:45:23 -07:00
|
|
|
Wrap,
|
|
|
|
WrapItem,
|
|
|
|
} from "@chakra-ui/react";
|
2021-06-18 18:38:56 -07:00
|
|
|
import {
|
|
|
|
ArrowForwardIcon,
|
2021-06-19 09:44:44 -07:00
|
|
|
CheckIcon,
|
2021-06-18 18:38:56 -07:00
|
|
|
ChevronRightIcon,
|
2021-06-19 09:44:44 -07:00
|
|
|
EditIcon,
|
2021-06-18 18:38:56 -07:00
|
|
|
EmailIcon,
|
|
|
|
} from "@chakra-ui/icons";
|
2021-06-19 09:44:44 -07:00
|
|
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
2021-06-12 04:45:23 -07:00
|
|
|
import { Link, useParams } from "react-router-dom";
|
|
|
|
import { HashLink } from "react-router-hash-link";
|
|
|
|
|
2021-06-19 09:44:44 -07:00
|
|
|
import { Heading1, Heading3, MajorErrorMessage, usePageTitle } from "./util";
|
2021-06-12 04:45:23 -07:00
|
|
|
import HangerSpinner from "./components/HangerSpinner";
|
2021-06-18 17:26:02 -07:00
|
|
|
import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
|
2021-06-12 04:45:23 -07:00
|
|
|
import ItemCard from "./components/ItemCard";
|
2021-06-18 17:53:17 -07:00
|
|
|
import useCurrentUser from "./components/useCurrentUser";
|
2021-06-19 09:44:44 -07:00
|
|
|
import useSupport from "./WardrobePage/support/useSupport";
|
|
|
|
import WIPCallout from "./components/WIPCallout";
|
2021-06-12 04:45:23 -07:00
|
|
|
|
|
|
|
function UserItemListPage() {
|
|
|
|
const { listId } = useParams();
|
2021-06-18 17:53:17 -07:00
|
|
|
const currentUser = useCurrentUser();
|
2021-06-12 04:45:23 -07:00
|
|
|
|
|
|
|
const { loading, error, data } = useQuery(
|
|
|
|
gql`
|
|
|
|
query UserItemListPage($listId: ID!) {
|
|
|
|
closetList(id: $listId) {
|
|
|
|
id
|
|
|
|
name
|
2021-06-18 17:26:02 -07:00
|
|
|
description
|
2021-06-12 04:45:23 -07:00
|
|
|
ownsOrWantsItems
|
|
|
|
creator {
|
|
|
|
id
|
|
|
|
username
|
2021-06-18 18:38:56 -07:00
|
|
|
contactNeopetsUsername
|
2021-06-12 04:45:23 -07:00
|
|
|
}
|
|
|
|
items {
|
|
|
|
id
|
|
|
|
isNc
|
|
|
|
isPb
|
|
|
|
name
|
|
|
|
thumbnailUrl
|
2021-06-18 17:53:17 -07:00
|
|
|
currentUserOwnsThis
|
|
|
|
currentUserWantsThis
|
2021-06-12 04:45:23 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`,
|
2021-06-18 17:26:02 -07:00
|
|
|
{ variables: { listId }, context: { sendAuth: true } }
|
2021-06-12 04:45:23 -07:00
|
|
|
);
|
|
|
|
|
2021-06-18 18:30:02 -07:00
|
|
|
const closetList = data?.closetList;
|
|
|
|
|
|
|
|
usePageTitle(closetList?.name);
|
|
|
|
|
2021-06-12 04:45:23 -07:00
|
|
|
if (loading) {
|
|
|
|
return (
|
|
|
|
<Center>
|
|
|
|
<HangerSpinner />
|
|
|
|
</Center>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
return <MajorErrorMessage error={error} variant="network" />;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!closetList) {
|
|
|
|
return <MajorErrorMessage variant="not-found" />;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { creator, ownsOrWantsItems } = closetList;
|
|
|
|
|
2021-06-18 17:53:17 -07:00
|
|
|
// NOTE: `currentUser` should have resolved by now, because the GraphQL query
|
|
|
|
// sends authorization, which requires the current user to load first!
|
|
|
|
const isCurrentUser = currentUser.id === creator.id;
|
|
|
|
|
2021-06-12 04:45:23 -07:00
|
|
|
let linkBackText;
|
|
|
|
let linkBackPath;
|
|
|
|
if (ownsOrWantsItems === "OWNS") {
|
|
|
|
linkBackText = `Items ${creator.username} owns`;
|
|
|
|
linkBackPath = `/user/${creator.id}/lists#owned-items`;
|
|
|
|
} else if (ownsOrWantsItems === "WANTS") {
|
|
|
|
linkBackText = `Items ${creator.username} wants`;
|
|
|
|
linkBackPath = `/user/${creator.id}/lists#wanted-items`;
|
|
|
|
} else {
|
|
|
|
throw new Error(`unexpected ownsOrWantsItems value: ${ownsOrWantsItems}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box>
|
|
|
|
<Breadcrumb
|
|
|
|
fontSize="sm"
|
|
|
|
opacity="0.8"
|
|
|
|
separator={<ChevronRightIcon marginTop="-2px" />}
|
|
|
|
>
|
|
|
|
<BreadcrumbItem>
|
|
|
|
<BreadcrumbLink as={Link} to={`/user/${creator.id}/lists`}>
|
|
|
|
{creator.username}'s lists
|
|
|
|
</BreadcrumbLink>
|
|
|
|
</BreadcrumbItem>
|
|
|
|
<BreadcrumbItem as={HashLink} to={linkBackPath}>
|
|
|
|
<BreadcrumbLink>{linkBackText}</BreadcrumbLink>
|
|
|
|
</BreadcrumbItem>
|
|
|
|
</Breadcrumb>
|
|
|
|
<Box height="1" />
|
2021-06-19 09:44:44 -07:00
|
|
|
<ClosetList
|
|
|
|
closetList={closetList}
|
|
|
|
isCurrentUser={isCurrentUser}
|
|
|
|
headingVariant="top-level"
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function ClosetList({
|
|
|
|
closetList,
|
|
|
|
isCurrentUser,
|
|
|
|
headingVariant = "list-item",
|
|
|
|
}) {
|
|
|
|
const { isSupportUser, supportSecret } = useSupport();
|
|
|
|
const toast = useToast();
|
|
|
|
|
|
|
|
// When this mounts, scroll it into view if it matches the location hash.
|
|
|
|
// This works around the fact that, while the browser tries to do this
|
|
|
|
// natively on page load, the list might not be mounted yet!
|
|
|
|
const anchorId = `list-${closetList.id}`;
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (document.location.hash === "#" + anchorId) {
|
|
|
|
document.getElementById(anchorId).scrollIntoView();
|
|
|
|
}
|
|
|
|
}, [anchorId]);
|
|
|
|
|
|
|
|
const [
|
|
|
|
sendSaveChangesMutation,
|
|
|
|
{ loading: loadingSaveChanges },
|
|
|
|
] = useMutation(
|
|
|
|
gql`
|
|
|
|
mutation ClosetList_Edit(
|
|
|
|
$closetListId: ID!
|
|
|
|
$name: String!
|
|
|
|
$description: String!
|
|
|
|
# Support users can edit any list, if they provide the secret. If you're
|
|
|
|
# editing your own list, this will be empty, and that's okay.
|
|
|
|
$supportSecret: String
|
|
|
|
) {
|
|
|
|
editClosetList(
|
|
|
|
closetListId: $closetListId
|
|
|
|
name: $name
|
|
|
|
description: $description
|
|
|
|
supportSecret: $supportSecret
|
|
|
|
) {
|
|
|
|
id
|
|
|
|
name
|
|
|
|
description
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
{ context: { sendAuth: true } }
|
|
|
|
);
|
|
|
|
|
|
|
|
const [isEditing, setIsEditing] = React.useState(false);
|
|
|
|
const [editableName, setEditableName] = React.useState(closetList.name);
|
|
|
|
const [editableDescription, setEditableDescription] = React.useState(
|
|
|
|
closetList.description
|
|
|
|
);
|
|
|
|
const hasChanges =
|
|
|
|
editableName !== closetList.name ||
|
|
|
|
editableDescription !== closetList.description;
|
|
|
|
const onSaveChanges = () => {
|
|
|
|
if (!hasChanges) {
|
|
|
|
setIsEditing(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
sendSaveChangesMutation({
|
|
|
|
variables: {
|
|
|
|
closetListId: closetList.id,
|
|
|
|
name: editableName,
|
|
|
|
description: editableDescription,
|
|
|
|
supportSecret,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
.then(() => {
|
|
|
|
setIsEditing(false);
|
|
|
|
toast({
|
|
|
|
status: "success",
|
|
|
|
title: "Changes saved!",
|
|
|
|
});
|
|
|
|
})
|
|
|
|
.catch((err) => {
|
|
|
|
console.error(err);
|
|
|
|
toast({
|
|
|
|
status: "error",
|
|
|
|
title: "Sorry, we couldn't save this list 😖",
|
|
|
|
description: "Check your connection and try again.",
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const Heading = headingVariant === "top-level" ? Heading1 : Heading3;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Box id={anchorId}>
|
|
|
|
<Flex align="center" wrap="wrap">
|
|
|
|
{headingVariant !== "hidden" &&
|
|
|
|
(isEditing ? (
|
|
|
|
<Heading
|
|
|
|
as={Input}
|
|
|
|
value={editableName}
|
|
|
|
onChange={(e) => setEditableName(e.target.value)}
|
|
|
|
maxWidth="20ch"
|
|
|
|
// Shift left by our own padding/border, for alignment with the
|
|
|
|
// original title
|
|
|
|
paddingX="0.75rem"
|
|
|
|
marginLeft="calc(-0.75rem - 1px)"
|
|
|
|
boxShadow="sm"
|
|
|
|
lineHeight="1.2"
|
|
|
|
// HACK: Idk, the height stuff is really getting away from me,
|
|
|
|
// this is close enough :/
|
|
|
|
height="1.2em"
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Heading
|
|
|
|
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
|
|
|
lineHeight="1.2" // to match Input
|
|
|
|
paddingY="2px" // to account for Input border/padding
|
2021-06-18 18:38:56 -07:00
|
|
|
>
|
2021-06-19 09:44:44 -07:00
|
|
|
{closetList.isDefaultList || headingVariant === "top-level" ? (
|
|
|
|
closetList.name
|
|
|
|
) : (
|
|
|
|
<Box
|
|
|
|
as={Link}
|
|
|
|
to={buildClosetListPath(closetList)}
|
|
|
|
_hover={{ textDecoration: "underline" }}
|
|
|
|
>
|
|
|
|
{closetList.name}
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</Heading>
|
|
|
|
))}
|
|
|
|
<Box flex="1 0 auto" width="4" />
|
|
|
|
{(isCurrentUser || isSupportUser) &&
|
|
|
|
!closetList.isDefaultList &&
|
|
|
|
(isEditing ? (
|
|
|
|
<>
|
|
|
|
<WIPCallout
|
|
|
|
size="sm"
|
|
|
|
details="To edit the items, head back to Classic DTI!"
|
|
|
|
marginY="2"
|
|
|
|
>
|
|
|
|
WIP: Can only edit text for now!
|
|
|
|
</WIPCallout>
|
|
|
|
<Box width="4" />
|
|
|
|
<HStack spacing="2" marginLeft="auto" marginY="1">
|
|
|
|
<Button size="sm" onClick={() => setIsEditing(false)}>
|
|
|
|
Cancel
|
|
|
|
</Button>
|
|
|
|
<Button
|
|
|
|
display="flex"
|
|
|
|
align="center"
|
|
|
|
size="sm"
|
|
|
|
colorScheme="green"
|
|
|
|
onClick={onSaveChanges}
|
|
|
|
isLoading={loadingSaveChanges}
|
|
|
|
>
|
|
|
|
<CheckIcon marginRight="1" />
|
|
|
|
Save changes
|
|
|
|
</Button>
|
|
|
|
</HStack>
|
|
|
|
</>
|
|
|
|
) : (
|
|
|
|
<Button
|
2021-06-18 18:38:56 -07:00
|
|
|
display="flex"
|
2021-06-19 09:44:44 -07:00
|
|
|
align="center"
|
|
|
|
size="sm"
|
|
|
|
onClick={() => setIsEditing(true)}
|
2021-06-18 18:38:56 -07:00
|
|
|
>
|
2021-06-19 09:44:44 -07:00
|
|
|
<EditIcon marginRight="1" />
|
|
|
|
Edit
|
|
|
|
</Button>
|
|
|
|
))}
|
|
|
|
</Flex>
|
|
|
|
{headingVariant === "top-level" && (
|
|
|
|
<Wrap spacing="2" opacity="0.7" marginBottom="2">
|
|
|
|
{closetList.creator?.contactNeopetsUsername && (
|
|
|
|
<WrapItem>
|
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href={`http://www.neopets.com/userlookup.phtml?user=${closetList.creator.contactNeopetsUsername}`}
|
|
|
|
display="flex"
|
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<NeopetsStarIcon marginRight="1" />
|
|
|
|
{closetList.creator.contactNeopetsUsername}
|
|
|
|
</Badge>
|
|
|
|
</WrapItem>
|
|
|
|
)}
|
|
|
|
{closetList.creator?.contactNeopetsUsername && (
|
|
|
|
<WrapItem>
|
|
|
|
<Badge
|
|
|
|
as="a"
|
|
|
|
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${closetList.creator.contactNeopetsUsername}`}
|
|
|
|
display="flex"
|
|
|
|
alignItems="center"
|
|
|
|
>
|
|
|
|
<EmailIcon marginRight="1" />
|
|
|
|
Neomail
|
|
|
|
</Badge>
|
|
|
|
</WrapItem>
|
|
|
|
)}
|
|
|
|
</Wrap>
|
|
|
|
)}
|
|
|
|
<Box height="2" />
|
2021-06-19 09:46:53 -07:00
|
|
|
{(closetList.description || isEditing) && (
|
2021-06-19 09:44:44 -07:00
|
|
|
<Box marginBottom="2">
|
|
|
|
{isEditing ? (
|
|
|
|
<Textarea
|
|
|
|
value={editableDescription}
|
|
|
|
onChange={(e) => setEditableDescription(e.target.value)}
|
2021-06-19 09:46:53 -07:00
|
|
|
placeholder="This is my list! I'm looking for…"
|
2021-06-19 09:44:44 -07:00
|
|
|
// Shift left by our own padding/border, for alignment with the
|
|
|
|
// original title
|
|
|
|
paddingX="0.75rem"
|
|
|
|
marginLeft="calc(-0.75rem - 1px)"
|
|
|
|
boxShadow="sm"
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
|
|
|
)}
|
|
|
|
</Box>
|
2021-06-18 17:26:02 -07:00
|
|
|
)}
|
2021-06-18 17:53:17 -07:00
|
|
|
<ClosetListContents
|
|
|
|
closetList={closetList}
|
|
|
|
isCurrentUser={isCurrentUser}
|
2021-06-19 09:44:44 -07:00
|
|
|
// For default lists, we don't have a separate page, we just inline
|
|
|
|
// them all here. This is a less-nice experience, but it simplifies
|
|
|
|
// the single-list page a lot to not have to care, and for now we just
|
|
|
|
// kinda expect that people who care about trade lists enough will
|
|
|
|
// group them into lists so it's nbd! ^_^`
|
|
|
|
maxNumItemsToShow={!closetList.isDefaultList ? 14 : null}
|
2021-06-18 17:53:17 -07:00
|
|
|
/>
|
2021-06-12 04:45:23 -07:00
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-06-18 18:23:50 -07:00
|
|
|
export function ClosetListContents({
|
|
|
|
closetList,
|
|
|
|
isCurrentUser,
|
|
|
|
maxNumItemsToShow = null,
|
|
|
|
}) {
|
2021-06-18 17:53:17 -07:00
|
|
|
const isTradeMatch = (item) =>
|
|
|
|
!isCurrentUser &&
|
|
|
|
((closetList.ownsOrWantsItems === "OWNS" && item.currentUserWantsThis) ||
|
|
|
|
(closetList.ownsOrWantsItems === "WANTS" && item.currentUserOwnsThis));
|
|
|
|
|
|
|
|
const sortedItems = [...closetList.items].sort((a, b) => {
|
|
|
|
// This is a cute sort hack. We sort first by, bringing trade matches to
|
|
|
|
// the top, and then sorting by name _within_ those two groups.
|
|
|
|
const aName = `${isTradeMatch(a) ? "000" : "999"} ${a.name}`;
|
|
|
|
const bName = `${isTradeMatch(b) ? "000" : "999"} ${b.name}`;
|
|
|
|
return aName.localeCompare(bName);
|
|
|
|
});
|
2021-06-12 04:45:23 -07:00
|
|
|
|
2021-06-18 18:23:50 -07:00
|
|
|
let itemsToShow = sortedItems;
|
|
|
|
if (maxNumItemsToShow != null) {
|
|
|
|
itemsToShow = itemsToShow.slice(0, maxNumItemsToShow);
|
|
|
|
}
|
|
|
|
|
|
|
|
const numMoreItems = Math.max(sortedItems.length - itemsToShow.length, 0);
|
|
|
|
|
2021-06-18 17:53:17 -07:00
|
|
|
let tradeMatchingMode;
|
|
|
|
if (isCurrentUser) {
|
|
|
|
// On your own item list, it's not helpful to show your own trade matches!
|
|
|
|
tradeMatchingMode = "hide-all";
|
|
|
|
} else if (closetList.ownsOrWantsItems === "OWNS") {
|
|
|
|
tradeMatchingMode = "offering";
|
|
|
|
} else if (closetList.ownsOrWantsItems === "WANTS") {
|
|
|
|
tradeMatchingMode = "seeking";
|
|
|
|
} else {
|
|
|
|
throw new Error(
|
|
|
|
`unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}`
|
|
|
|
);
|
|
|
|
}
|
2021-06-12 04:45:23 -07:00
|
|
|
|
|
|
|
return (
|
|
|
|
<Box>
|
2021-06-18 18:23:50 -07:00
|
|
|
{itemsToShow.length > 0 ? (
|
2021-06-12 04:45:23 -07:00
|
|
|
<Wrap spacing="4" justify="center">
|
2021-06-18 18:23:50 -07:00
|
|
|
{itemsToShow.map((item) => (
|
2021-06-12 04:45:23 -07:00
|
|
|
<WrapItem key={item.id}>
|
|
|
|
<ItemCard
|
|
|
|
item={item}
|
|
|
|
variant="grid"
|
|
|
|
tradeMatchingMode={tradeMatchingMode}
|
|
|
|
/>
|
|
|
|
</WrapItem>
|
|
|
|
))}
|
|
|
|
</Wrap>
|
|
|
|
) : (
|
|
|
|
<Box fontStyle="italic">This list is empty!</Box>
|
|
|
|
)}
|
2021-06-18 18:23:50 -07:00
|
|
|
{numMoreItems > 0 && (
|
|
|
|
<Box
|
|
|
|
as={Link}
|
|
|
|
to={buildClosetListPath(closetList)}
|
|
|
|
display="flex"
|
|
|
|
width="100%"
|
|
|
|
alignItems="center"
|
|
|
|
justifyContent="center"
|
|
|
|
marginTop="6"
|
|
|
|
fontStyle="italic"
|
|
|
|
textAlign="center"
|
|
|
|
role="group"
|
|
|
|
>
|
|
|
|
<Flex
|
|
|
|
align="center"
|
|
|
|
borderBottom="1px solid transparent"
|
|
|
|
_groupHover={{ borderBottomColor: "currentColor" }}
|
|
|
|
_groupFocus={{ borderBottomColor: "currentColor" }}
|
|
|
|
>
|
|
|
|
<Box>Show {numMoreItems} more items</Box>
|
|
|
|
<Box width="1" />
|
|
|
|
<ArrowForwardIcon />
|
|
|
|
</Flex>
|
|
|
|
</Box>
|
|
|
|
)}
|
2021-06-12 04:45:23 -07:00
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-06-18 18:23:50 -07:00
|
|
|
export function buildClosetListPath(closetList) {
|
|
|
|
let ownsOrWants;
|
|
|
|
if (closetList.ownsOrWantsItems === "OWNS") {
|
|
|
|
ownsOrWants = "owns";
|
|
|
|
} else if (closetList.ownsOrWantsItems === "WANTS") {
|
|
|
|
ownsOrWants = "wants";
|
|
|
|
} else {
|
|
|
|
throw new Error(
|
|
|
|
`unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${closetList.id}`;
|
|
|
|
}
|
|
|
|
|
2021-06-18 18:38:56 -07:00
|
|
|
export function NeopetsStarIcon(props) {
|
|
|
|
// Converted from the Neopets favicon with https://www.vectorizer.io/.
|
|
|
|
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>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-06-12 04:45:23 -07:00
|
|
|
export default UserItemListPage;
|