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"