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-dom": "^17.0.1",
|
||||||
"react-icons": "^4.2.0",
|
"react-icons": "^4.2.0",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
|
"react-router-hash-link": "^2.4.3",
|
||||||
"react-scripts": "^4.0.1",
|
"react-scripts": "^4.0.1",
|
||||||
"react-transition-group": "^4.3.0",
|
"react-transition-group": "^4.3.0",
|
||||||
"simple-markdown": "^0.7.2",
|
"simple-markdown": "^0.7.2",
|
||||||
|
|
|
@ -35,6 +35,7 @@ const SupportPetAppearancesPage = loadable(() =>
|
||||||
import("./SupportPetAppearancesPage")
|
import("./SupportPetAppearancesPage")
|
||||||
);
|
);
|
||||||
const UserItemsPage = loadable(() => import("./UserItemsPage"));
|
const UserItemsPage = loadable(() => import("./UserItemsPage"));
|
||||||
|
const UserItemListPage = loadable(() => import("./UserItemListPage"));
|
||||||
const UserOutfitsPage = loadable(() => import("./UserOutfitsPage"));
|
const UserOutfitsPage = loadable(() => import("./UserOutfitsPage"));
|
||||||
const WardrobePage = loadable(() => import("./WardrobePage"), {
|
const WardrobePage = loadable(() => import("./WardrobePage"), {
|
||||||
fallback: <WardrobePageLayout />,
|
fallback: <WardrobePageLayout />,
|
||||||
|
@ -135,6 +136,11 @@ function App() {
|
||||||
<Route path="/outfits/:id">
|
<Route path="/outfits/:id">
|
||||||
<WardrobePage />
|
<WardrobePage />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/user/:userId/lists/:ownsOrWants(owns|wants)/:listId">
|
||||||
|
<PageLayout>
|
||||||
|
<UserItemListPage />
|
||||||
|
</PageLayout>
|
||||||
|
</Route>
|
||||||
<Route path="/user/:userId/lists">
|
<Route path="/user/:userId/lists">
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<UserItemsPage />
|
<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,
|
StarIcon,
|
||||||
} from "@chakra-ui/icons";
|
} from "@chakra-ui/icons";
|
||||||
import gql from "graphql-tag";
|
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 { useQuery, useLazyQuery, useMutation } from "@apollo/client";
|
||||||
import SimpleMarkdown from "simple-markdown";
|
import SimpleMarkdown from "simple-markdown";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
@ -76,10 +76,9 @@ function UserItemsPage() {
|
||||||
thumbnailUrl
|
thumbnailUrl
|
||||||
currentUserOwnsThis
|
currentUserOwnsThis
|
||||||
currentUserWantsThis
|
currentUserWantsThis
|
||||||
allOccupiedZones {
|
}
|
||||||
id
|
creator {
|
||||||
label @client
|
id
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -550,7 +549,13 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
||||||
lineHeight="1.2" // to match Input
|
lineHeight="1.2" // to match Input
|
||||||
paddingY="2px" // to account for Input border/padding
|
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>
|
</Heading3>
|
||||||
))}
|
))}
|
||||||
<Box flex="1 0 auto" width="4" />
|
<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 = {
|
const unsafeMarkdownRules = {
|
||||||
autolink: SimpleMarkdown.defaultRules.autolink,
|
autolink: SimpleMarkdown.defaultRules.autolink,
|
||||||
br: SimpleMarkdown.defaultRules.br,
|
br: SimpleMarkdown.defaultRules.br,
|
||||||
|
|
|
@ -28,7 +28,6 @@ function WIPCallout({
|
||||||
paddingRight="4"
|
paddingRight="4"
|
||||||
paddingY="1"
|
paddingY="1"
|
||||||
fontSize={size === "sm" ? "xs" : "sm"}
|
fontSize={size === "sm" ? "xs" : "sm"}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
as="img"
|
as="img"
|
||||||
|
@ -62,6 +61,8 @@ function WIPCallout({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content = <Box {...props}>{content}</Box>;
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,21 @@ const buildClosetListLoader = (db) =>
|
||||||
return ids.map((id) => entities.find((e) => e.id === id));
|
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 buildColorLoader = (db) => {
|
||||||
const colorLoader = new DataLoader(async (colorIds) => {
|
const colorLoader = new DataLoader(async (colorIds) => {
|
||||||
const qs = colorIds.map((_) => "?").join(",");
|
const qs = colorIds.map((_) => "?").join(",");
|
||||||
|
@ -1227,6 +1242,7 @@ function buildLoaders(db) {
|
||||||
loaders.loadAllPetTypes = loadAllPetTypes(db);
|
loaders.loadAllPetTypes = loadAllPetTypes(db);
|
||||||
|
|
||||||
loaders.closetListLoader = buildClosetListLoader(db);
|
loaders.closetListLoader = buildClosetListLoader(db);
|
||||||
|
loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db);
|
||||||
loaders.colorLoader = buildColorLoader(db);
|
loaders.colorLoader = buildColorLoader(db);
|
||||||
loaders.colorTranslationLoader = buildColorTranslationLoader(db);
|
loaders.colorTranslationLoader = buildColorTranslationLoader(db);
|
||||||
loaders.itemLoader = buildItemLoader(db);
|
loaders.itemLoader = buildItemLoader(db);
|
||||||
|
|
|
@ -32,6 +32,15 @@ const typeDefs = gql`
|
||||||
isDefaultList: Boolean!
|
isDefaultList: Boolean!
|
||||||
|
|
||||||
items: [Item!]!
|
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 {
|
extend type Mutation {
|
||||||
|
@ -93,22 +102,51 @@ const resolvers = {
|
||||||
return Boolean(isDefaultList);
|
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
|
// 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
|
// once, this is provided in the returned object. Just use it!
|
||||||
// we separated out the ClosetList resolvers at all! But I'm not
|
// TODO: Might be better to prime the loader with this instead?
|
||||||
// bothering to port it, because it would mean writing a new
|
if (precomputedItems) {
|
||||||
// loader, and we don't yet have any endpoints that actually need
|
return precomputedItems;
|
||||||
// this.
|
|
||||||
if (items) {
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
// TODO: Support the not-in-a-list case!
|
||||||
`TODO: Not implemented, we still duplicate / bulk-implement some of ` +
|
const closetHangers = await closetHangersForListLoader.load(id);
|
||||||
`the list resolver stuff in User.js. Break that out into real ` +
|
const itemIds = closetHangers.map((h) => h.itemId);
|
||||||
`ClosetList loaders and resolvers!`
|
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-invariant "^1.0.2"
|
||||||
tiny-warning "^1.0.0"
|
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:
|
react-router@5.1.2:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"
|
||||||
|
|
Loading…
Reference in a new issue