First draft of UserItemListPage
A lot is missing! No descriptions, no support for the "Not in a list" case, no scroll performance windowing, no editing! But it's a start :3
This commit is contained in:
parent
232e35e062
commit
cf30b25be0
8 changed files with 281 additions and 20 deletions
|
@ -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",
|
||||
|
|
|
@ -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: <WardrobePageLayout />,
|
||||
|
@ -135,6 +136,11 @@ function App() {
|
|||
<Route path="/outfits/:id">
|
||||
<WardrobePage />
|
||||
</Route>
|
||||
<Route path="/user/:userId/lists/:ownsOrWants(owns|wants)/:listId">
|
||||
<PageLayout>
|
||||
<UserItemListPage />
|
||||
</PageLayout>
|
||||
</Route>
|
||||
<Route path="/user/:userId/lists">
|
||||
<PageLayout>
|
||||
<UserItemsPage />
|
||||
|
|
170
src/app/UserItemListPage.js
Normal file
170
src/app/UserItemListPage.js
Normal file
|
@ -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 (
|
||||
<Center>
|
||||
<HangerSpinner />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <MajorErrorMessage error={error} variant="network" />;
|
||||
}
|
||||
|
||||
const closetList = data?.closetList;
|
||||
if (!closetList) {
|
||||
return <MajorErrorMessage variant="not-found" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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" />
|
||||
<Flex wrap="wrap">
|
||||
<Heading1>{closetList.name}</Heading1>
|
||||
<WIPCallout
|
||||
marginLeft="auto"
|
||||
details="We're planning to make this the detail view for each list, and then your lists page will be a easy-to-scan summary!"
|
||||
/>
|
||||
</Flex>
|
||||
<Box height="6" />
|
||||
{/* TODO: Description */}
|
||||
<ClosetListContents closetList={closetList} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
{sortedItems.length > 0 ? (
|
||||
<Wrap spacing="4" justify="center">
|
||||
{sortedItems.map((item) => (
|
||||
<WrapItem key={item.id}>
|
||||
<ItemCard
|
||||
item={item}
|
||||
variant="grid"
|
||||
tradeMatchingMode={tradeMatchingMode}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
) : (
|
||||
<Box fontStyle="italic">This list is empty!</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserItemListPage;
|
|
@ -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 : <Box
|
||||
as={Link}
|
||||
to={buildClosetListPath(closetList)}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
{closetList.name}
|
||||
</Box>}
|
||||
</Heading3>
|
||||
))}
|
||||
<Box flex="1 0 auto" width="4" />
|
||||
|
@ -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,
|
||||
|
|
|
@ -28,7 +28,6 @@ function WIPCallout({
|
|||
paddingRight="4"
|
||||
paddingY="1"
|
||||
fontSize={size === "sm" ? "xs" : "sm"}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
as="img"
|
||||
|
@ -62,6 +61,8 @@ function WIPCallout({
|
|||
);
|
||||
}
|
||||
|
||||
content = <Box {...props}>{content}</Box>;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue