1
0
Fork 0
forked from OpenNeo/impress
impress/src/app/UserItemListPage.js
Matchu 5d28c36e8a [WIP] Migrate single-list page to Next.js routing
Hey we're getting real close! :3

I accepted a small bug here where clicking the breadcrumb to "Items X wants" won't scroll down if the page isn't loaded yet. (e.g. you landed on this list page first). If you came *from* the lists index page though, then when you go back your stuff will be there already, so you should be fine. (It might also happen if the page loads fast enough, which in prod it might do?)

Just gonna leave it for now, because the workaround would be a lot! (have the page re-check the anchor once it's done loading)
2022-09-14 23:18:13 -07:00

679 lines
20 KiB
JavaScript

import React from "react";
import {
Badge,
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Button,
Center,
Flex,
HStack,
Input,
Textarea,
useToast,
Wrap,
WrapItem,
} from "@chakra-ui/react";
import {
ArrowForwardIcon,
CheckIcon,
ChevronRightIcon,
EditIcon,
EmailIcon,
} from "@chakra-ui/icons";
import { gql, useMutation, useQuery } from "@apollo/client";
import Link from "next/link";
import { useRouter } from "next/router";
import {
List as VirtualizedList,
AutoSizer,
WindowScroller,
} from "react-virtualized";
import { Heading1, Heading3, MajorErrorMessage, usePageTitle } from "./util";
import HangerSpinner from "./components/HangerSpinner";
import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
import ItemCard from "./components/ItemCard";
import useCurrentUser from "./components/useCurrentUser";
import useSupport from "./WardrobePage/support/useSupport";
import WIPCallout from "./components/WIPCallout";
function UserItemListPage() {
const { query } = useRouter();
const { listId } = query;
const currentUser = useCurrentUser();
const { loading, error, data } = useQuery(
gql`
query UserItemListPage($listId: ID!) {
closetList(id: $listId) {
id
name
description
ownsOrWantsItems
creator {
id
username
contactNeopetsUsername
}
items {
id
isNc
isPb
name
thumbnailUrl
currentUserOwnsThis
currentUserWantsThis
}
}
}
`,
{ variables: { listId }, context: { sendAuth: true } }
);
const closetList = data?.closetList;
usePageTitle(closetList?.name);
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;
// 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;
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>
<Link href={`/user/${creator.id}/lists`} passHref>
<BreadcrumbLink>{creator.username}'s lists</BreadcrumbLink>
</Link>
</BreadcrumbItem>
{/* TODO: The "wants" version of this link doesn't always scroll down
* the page properly, now that we're not using the
* react-router-hash-link library. Oh well for now!
* Would be nice to fix by having Next.js eliminate the loading
* spinner perhaps?...*/}
<BreadcrumbItem>
<Link href={linkBackPath} passHref>
<BreadcrumbLink>{linkBackText}</BreadcrumbLink>
</Link>
</BreadcrumbItem>
</Breadcrumb>
<Box height="1" />
<ClosetList
closetList={closetList}
isCurrentUser={isCurrentUser}
headingVariant="top-level"
/>
</Box>
);
}
export function ClosetList({
closetList,
isCurrentUser,
headingVariant = "list-item",
maxNumItemsToShow = null,
}) {
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
>
{closetList.isDefaultList || headingVariant === "top-level" ? (
closetList.name
) : (
<Link href={buildClosetListPath(closetList)} passHref>
<Box as="a" _hover={{ textDecoration: "underline" }}>
{closetList.name}
</Box>
</Link>
)}
</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
display="flex"
align="center"
size="sm"
onClick={() => setIsEditing(true)}
>
<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" />
{(closetList.description || isEditing) && (
<Box marginBottom="2">
{isEditing ? (
<Textarea
value={editableDescription}
onChange={(e) => setEditableDescription(e.target.value)}
placeholder="This is my list! I'm looking for…"
// Shift left by our own padding/border, for alignment with the
// original title
paddingX="0.75rem"
marginLeft="calc(-0.75rem - 1px)"
boxShadow="sm"
/>
) : (
<MarkdownAndSafeHTML onBeforeSanitizeNode={rewriteLinkAnchors}>
{closetList.description}
</MarkdownAndSafeHTML>
)}
</Box>
)}
<ClosetListContents
closetList={closetList}
isCurrentUser={isCurrentUser}
maxNumItemsToShow={maxNumItemsToShow}
/>
</Box>
);
}
export function ClosetListContents({
closetList,
isCurrentUser,
maxNumItemsToShow = null,
}) {
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);
});
let itemsToShow = sortedItems;
if (maxNumItemsToShow != null) {
itemsToShow = itemsToShow.slice(0, maxNumItemsToShow);
}
const numMoreItems = Math.max(sortedItems.length - itemsToShow.length, 0);
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}`
);
}
return (
<Box>
{itemsToShow.length > 0 ? (
<ClosetItemList
itemsToShow={itemsToShow}
closetList={closetList}
canEdit={isCurrentUser}
tradeMatchingMode={tradeMatchingMode}
/>
) : (
<Box fontStyle="italic">This list is empty!</Box>
)}
{numMoreItems > 0 && (
<Link href={buildClosetListPath(closetList)} passHref>
<Box
as="a"
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>
</Link>
)}
</Box>
);
}
// HACK: Measured by hand from <SquareItemCard />, plus 16px padding.
const ITEM_CARD_WIDTH = 112 + 16;
const ITEM_CARD_HEIGHT = 171 + 16;
function ClosetItemList({
itemsToShow,
closetList,
canEdit,
tradeMatchingMode,
}) {
const renderItem = (item) => (
<ClosetListItemCard
key={item.id}
item={item}
closetList={closetList}
canEdit={canEdit}
tradeMatchingMode={tradeMatchingMode}
/>
);
// For small lists, we don't bother to virtualize, because it slows down
// scrolling! (This helps a lot on the lists index page.)
if (itemsToShow.length < 30) {
return (
<Wrap spacing="4" justify="center">
{itemsToShow.map((item) => (
<WrapItem key={item.id}>{renderItem(item)}</WrapItem>
))}
</Wrap>
);
}
return (
<WindowScroller>
{({ height, isScrolling, onChildScroll, scrollTop, registerChild }) => (
<AutoSizer disableHeight>
{({ width }) => {
const numItemsPerRow = Math.floor(width / ITEM_CARD_WIDTH);
const numRows = Math.ceil(itemsToShow.length / numItemsPerRow);
return (
<div ref={registerChild}>
<VirtualizedList
autoHeight
height={height}
width={width}
rowCount={numRows}
rowHeight={ITEM_CARD_HEIGHT}
rowRenderer={({ index: rowIndex, key, style }) => {
const firstItemIndex = rowIndex * numItemsPerRow;
const itemsForRow = itemsToShow.slice(
firstItemIndex,
firstItemIndex + numItemsPerRow
);
return (
<HStack
key={key}
style={style}
spacing="4"
align="flex-top"
justify="center"
// We need to provide some extra space up top, so that
// the virtualized row (which has overflow: hidden)
// doesn't cut off overflowing effects like trade match
// shadows.
paddingTop="2"
>
{itemsForRow.map(renderItem)}
</HStack>
);
}}
isScrolling={isScrolling}
onScroll={onChildScroll}
scrollTop={scrollTop}
/>
</div>
);
}}
</AutoSizer>
)}
</WindowScroller>
);
}
function ClosetListItemCard({ item, closetList, canEdit, tradeMatchingMode }) {
const toast = useToast();
const [sendRemoveMutation] = useMutation(
gql`
mutation ClosetListItemCard_RemoveItem($listId: ID!, $itemId: ID!) {
removeItemFromClosetList(listId: $listId, itemId: $itemId) {
id
items {
id
}
}
}
`,
{ context: { sendAuth: true } }
);
const onRemove = React.useCallback(() => {
sendRemoveMutation({
variables: { itemId: item.id, listId: closetList.id },
optimisticResponse: {
removeItemFromClosetList: {
id: closetList.id,
__typename: "ClosetList",
items: closetList.items
.filter(({ id }) => id !== item.id)
.map(({ id }) => ({ id, __typename: "Item" })),
},
},
}).catch((error) => {
console.error(error);
toast({
status: "error",
title: `Oops, we couldn't remove "${item.name}" from this list!`,
description: "Check your connection and try again. Sorry!",
});
});
}, [sendRemoveMutation, item, closetList, toast]);
return (
<ItemCard
key={item.id}
item={item}
variant="grid"
tradeMatchingMode={tradeMatchingMode}
showRemoveButton={canEdit}
onRemove={onRemove}
/>
);
}
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}`;
}
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"
fillRule="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>
);
}
/**
* Rewrite old-style links from Classic DTI, for compatibility with our new
* URLs and anchor syntax.
*/
function rewriteLinkAnchors(node) {
try {
// Only rewrite `a` tags, and only if they point to DTI (either classic
// DTI, or a path relative to the current host.)
if (node.nodeName === "A") {
const url = new URL(node.href);
if (
url.host === "impress.openneo.net" ||
url.host === window.location.host
) {
// Rewrite the `closet` path component to `lists`.
const closetPathMatch = url.pathname.match(/^\/user\/([^/]+)\/closet$/);
if (closetPathMatch) {
url.pathname = `/user/${closetPathMatch[1]}/lists`;
}
// Rewrite `#closet-list-123` to `#list-123`.
const closetListAnchorMatch = url.hash.match(/^#closet-list-(.+)$/);
if (closetListAnchorMatch) {
url.hash = "#list-" + closetListAnchorMatch[1];
}
// Rewrite `#closet-hangers-group-true` to `#owned-items`.
if (url.hash === "#closet-hangers-group-true") {
url.hash = "#owned-items";
}
// Rewrite `#closet-hangers-group-false` to `#wanted-items`.
if (url.hash === "#closet-hangers-group-false") {
url.hash = "#wanted-items";
}
// Set the href to be an absolute path to the new URL. (We do this
// rather than url.toString(), because that will return a full absolute
// URL, and DOMPurify might not have the current host in its allow list,
// e.g. in dev or in a preview deploy.)
node.href = url.pathname + url.search + url.hash;
}
}
} catch (e) {
// None of these operations are safety operations; they're just to make
// old-style links compatible. If processing fails, then they'll just be
// judged for safety by DOMPurify and deleted if unsafe, which is fine!
// So, we allow processing to succeed, and just log the error.
console.error("[rewriteLinkAnchors] Could not rewrite node", node, e);
}
}
export default UserItemListPage;