diff --git a/package.json b/package.json index 82e9414..0b7c090 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-dom": "^17.0.1", "react-icons": "^4.2.0", "react-router-dom": "^5.1.2", + "react-router-hash-link": "^2.4.3", "react-scripts": "^4.0.1", "react-transition-group": "^4.3.0", "simple-markdown": "^0.7.2", diff --git a/src/app/App.js b/src/app/App.js index 3522d46..cdae1d6 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -35,6 +35,7 @@ const SupportPetAppearancesPage = loadable(() => import("./SupportPetAppearancesPage") ); const UserItemsPage = loadable(() => import("./UserItemsPage")); +const UserItemListPage = loadable(() => import("./UserItemListPage")); const UserOutfitsPage = loadable(() => import("./UserOutfitsPage")); const WardrobePage = loadable(() => import("./WardrobePage"), { fallback: , @@ -135,6 +136,11 @@ function App() { + + + + + diff --git a/src/app/UserItemListPage.js b/src/app/UserItemListPage.js new file mode 100644 index 0000000..cb34bd0 --- /dev/null +++ b/src/app/UserItemListPage.js @@ -0,0 +1,170 @@ +import React from "react"; +import { + Box, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Center, + Flex, + Wrap, + WrapItem, +} from "@chakra-ui/react"; +import { Heading1, MajorErrorMessage } from "./util"; +import { gql, useQuery } from "@apollo/client"; +import { Link, useParams } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; + +import HangerSpinner from "./components/HangerSpinner"; +import { ChevronRightIcon } from "@chakra-ui/icons"; +import ItemCard from "./components/ItemCard"; +import WIPCallout from "./components/WIPCallout"; + +function UserItemListPage() { + const { listId } = useParams(); + + const { loading, error, data } = useQuery( + gql` + query UserItemListPage($listId: ID!) { + closetList(id: $listId) { + id + name + ownsOrWantsItems + creator { + id + username + } + items { + id + isNc + isPb + name + thumbnailUrl + } + } + } + `, + { variables: { listId } } + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ; + } + + const closetList = data?.closetList; + if (!closetList) { + return ; + } + + const { creator, ownsOrWantsItems } = closetList; + + 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 ( + + } + > + + + {creator.username}'s lists + + + + {linkBackText} + + + + + {closetList.name} + + + + {/* TODO: Description */} + + + ); +} + +function ClosetListContents({ closetList }) { + const isCurrentUser = false; // TODO + + // TODO: A lot of this is duplicated from UserItemsPage, find shared + // abstractions! + 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); + }); + + 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 ( + + {sortedItems.length > 0 ? ( + + {sortedItems.map((item) => ( + + + + ))} + + ) : ( + This list is empty! + )} + + ); +} + +export default UserItemListPage; diff --git a/src/app/UserItemsPage.js b/src/app/UserItemsPage.js index b978da6..6ce96c2 100644 --- a/src/app/UserItemsPage.js +++ b/src/app/UserItemsPage.js @@ -32,7 +32,7 @@ import { StarIcon, } from "@chakra-ui/icons"; import gql from "graphql-tag"; -import { useHistory, useParams } from "react-router-dom"; +import { Link, useHistory, useParams } from "react-router-dom"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; import SimpleMarkdown from "simple-markdown"; import DOMPurify from "dompurify"; @@ -76,10 +76,9 @@ function UserItemsPage() { thumbnailUrl currentUserOwnsThis currentUserWantsThis - allOccupiedZones { - id - label @client - } + } + creator { + id } } } @@ -550,7 +549,13 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) { lineHeight="1.2" // to match Input paddingY="2px" // to account for Input border/padding > - {closetList.name} + {closetList.isDefaultList ? closetList.name : + {closetList.name} + } ))} @@ -631,6 +636,23 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) { ); } +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}` + ); + } + + const idString = closetList.isDefaultList ? "not-in-a-list" : closetList.id; + + return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${idString}`; +} + const unsafeMarkdownRules = { autolink: SimpleMarkdown.defaultRules.autolink, br: SimpleMarkdown.defaultRules.br, diff --git a/src/app/components/WIPCallout.js b/src/app/components/WIPCallout.js index 8547264..2a9adb9 100644 --- a/src/app/components/WIPCallout.js +++ b/src/app/components/WIPCallout.js @@ -28,7 +28,6 @@ function WIPCallout({ paddingRight="4" paddingY="1" fontSize={size === "sm" ? "xs" : "sm"} - {...props} > {content}; + return content; } diff --git a/src/server/loaders.js b/src/server/loaders.js index 8c3c1b6..6d16469 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -15,6 +15,21 @@ const buildClosetListLoader = (db) => return ids.map((id) => entities.find((e) => e.id === id)); }); +const buildClosetHangersForListLoader = (db) => + new DataLoader(async (closetListIds) => { + const qs = closetListIds.map((_) => "?").join(","); + const [rows] = await db.execute( + `SELECT * FROM closet_hangers WHERE list_id IN (${qs})`, + closetListIds + ); + + const entities = rows.map(normalizeRow); + + return closetListIds.map((closetListId) => + entities.filter((e) => e.listId === closetListId) + ); + }); + const buildColorLoader = (db) => { const colorLoader = new DataLoader(async (colorIds) => { const qs = colorIds.map((_) => "?").join(","); @@ -1227,6 +1242,7 @@ function buildLoaders(db) { loaders.loadAllPetTypes = loadAllPetTypes(db); loaders.closetListLoader = buildClosetListLoader(db); + loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db); loaders.colorLoader = buildColorLoader(db); loaders.colorTranslationLoader = buildColorTranslationLoader(db); loaders.itemLoader = buildItemLoader(db); diff --git a/src/server/types/ClosetList.js b/src/server/types/ClosetList.js index f3926ba..1189a9b 100644 --- a/src/server/types/ClosetList.js +++ b/src/server/types/ClosetList.js @@ -32,6 +32,15 @@ const typeDefs = gql` isDefaultList: Boolean! items: [Item!]! + + # The user that created this list. + creator: User! + } + + extend type Query { + # The closet list with the given ID. Will be null if it doesn't exist, or + # if you're not allowed to see it. + closetList(id: ID!): ClosetList } extend type Mutation { @@ -93,22 +102,51 @@ const resolvers = { return Boolean(isDefaultList); }, - items: ({ items }) => { + items: async ( + { id, items: precomputedItems }, + _, + { itemLoader, closetHangersForListLoader } + ) => { // HACK: When called from User.js, for fetching all of a user's lists at - // once, this is provided in the returned object. This was before - // we separated out the ClosetList resolvers at all! But I'm not - // bothering to port it, because it would mean writing a new - // loader, and we don't yet have any endpoints that actually need - // this. - if (items) { - return items; + // once, this is provided in the returned object. Just use it! + // TODO: Might be better to prime the loader with this instead? + if (precomputedItems) { + return precomputedItems; } - throw new Error( - `TODO: Not implemented, we still duplicate / bulk-implement some of ` + - `the list resolver stuff in User.js. Break that out into real ` + - `ClosetList loaders and resolvers!` - ); + // TODO: Support the not-in-a-list case! + const closetHangers = await closetHangersForListLoader.load(id); + const itemIds = closetHangers.map((h) => h.itemId); + const items = await itemLoader.loadMany(itemIds); + + return items.map(({ id }) => ({ id })); + }, + + creator: async ({ id, isDefaultList, userId }, _, { closetListLoader }) => { + if (isDefaultList) { + return { id: userId }; + } + + const closetList = await closetListLoader.load(id); + return { id: closetList.userId }; + }, + }, + + Query: { + closetList: async (_, { id, currentUserId }, { closetListLoader }) => { + // TODO: Accept the `not-in-a-list` case too! + const closetList = await closetListLoader.load(id); + if (!closetList) { + return null; + } + + const canView = + closetList.userId === currentUserId || closetList.visibility >= 1; + if (!canView) { + return null; + } + + return { id }; }, }, diff --git a/yarn.lock b/yarn.lock index fc43cec..e02d4e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16025,6 +16025,13 @@ react-router-dom@^5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-hash-link@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.4.3.tgz#570824d53d6c35ce94d73a46c8e98673a127bf08" + integrity sha512-NU7GWc265m92xh/aYD79Vr1W+zAIXDWp3L2YZOYP4rCqPnJ6LI6vh3+rKgkidtYijozHclaEQTAHaAaMWPVI4A== + dependencies: + prop-types "^15.7.2" + react-router@5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"