diff --git a/src/app/HomePage.js b/src/app/HomePage.js index 8714f9a..685495e 100644 --- a/src/app/HomePage.js +++ b/src/app/HomePage.js @@ -5,6 +5,7 @@ import { Box, Button, Flex, + HStack, Input, Textarea, useColorModeValue, @@ -13,16 +14,21 @@ import { VStack, } from "@chakra-ui/react"; import { useHistory, useLocation } from "react-router-dom"; -import { useLazyQuery } from "@apollo/client"; +import { useLazyQuery, useQuery } from "@apollo/client"; import { + ErrorMessage, Heading1, + Heading2, useCommonStyles, useLocalStorage, usePageTitle, } from "./util"; import OutfitPreview from "./components/OutfitPreview"; import SpeciesColorPicker from "./components/SpeciesColorPicker"; +import SquareItemCard, { + SquareItemCardSkeleton, +} from "./components/SquareItemCard"; import WIPCallout from "./components/WIPCallout"; import HomepageSplashImg from "./images/homepage-splash.png"; @@ -81,6 +87,8 @@ function HomePage() { + + @@ -269,6 +277,85 @@ function SubmitPetForm() { ); } +function NewItemsSection() { + return ( + + Latest items + + + ); +} + +function NewItemsSectionContent() { + const { loading, error, data } = useQuery(gql` + query NewItemsSection { + newestItems { + id + name + thumbnailUrl + } + } + `); + + if (loading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); + } + + if (error) { + return ( + + Couldn't load new items. Check your connection and try again! + + ); + } + + return ( + + {data.newestItems.map((item) => ( + + ))} + + ); +} + +function ItemCardHStack({ children }) { + return ( + // HACK: I wanted to just have an HStack with overflow:auto and internal + // paddingX, but the right-hand-side padding didn't seem to work + // during overflow. This was the best I could come up with... + + + + {children} + + + + ); +} + function FeedbackFormSection() { const { brightBackground } = useCommonStyles(); const pitchBorderColor = useColorModeValue("gray.300", "green.400"); diff --git a/src/app/components/ItemCard.js b/src/app/components/ItemCard.js index b167a9e..134aff4 100644 --- a/src/app/components/ItemCard.js +++ b/src/app/components/ItemCard.js @@ -9,81 +9,20 @@ import { WrapItem, useColorModeValue, useTheme, - useToken, } from "@chakra-ui/react"; import { CheckIcon, NotAllowedIcon, StarIcon } from "@chakra-ui/icons"; import { HiSparkles } from "react-icons/hi"; import { Link } from "react-router-dom"; +import SquareItemCard from "./SquareItemCard"; import { safeImageUrl, useCommonStyles } from "../util"; function ItemCard({ item, badges, variant = "list", ...props }) { const { brightBackground } = useCommonStyles(); - const brightBackgroundValue = useToken("colors", brightBackground); - const theme = useTheme(); switch (variant) { case "grid": - return ( - // ItemCard renders in large lists of 1k+ items, so we get a big perf - // win by using Emotion directly instead of Chakra's styled-system Box. - - {({ css }) => ( - -
- {`Thumbnail -
- {item.name} -
-
- - )} -
- ); + return ; case "list": return ( + {({ css }) => ( + // SquareItemCard renders in large lists of 1k+ items, so we get a big + // perf win by using Emotion directly instead of Chakra's styled-system + // Box. + + + } + /> + + )} + + ); +} + +function SquareItemCardLayout({ name, thumbnailImage, minHeightNumLines = 2 }) { + const { brightBackground } = useCommonStyles(); + const brightBackgroundValue = useToken("colors", brightBackground); + const theme = useTheme(); + + return ( + // SquareItemCard renders in large lists of 1k+ items, so we get a big perf + // win by using Emotion directly instead of Chakra's styled-system Box. + + {({ css }) => ( +
+ {thumbnailImage} +
+ {name} +
+
+ )} +
+ ); +} + +export function SquareItemCardSkeleton({ minHeightNumLines }) { + return ( + + + {minHeightNumLines >= 3 && ( + + )} + + } + thumbnailImage={} + minHeightNumLines={minHeightNumLines} + /> + ); +} + +export default SquareItemCard; diff --git a/src/server/loaders.js b/src/server/loaders.js index 1257888..ea1670a 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -376,6 +376,29 @@ const buildItemSearchToFitLoader = (db, loaders) => return responses; }); +const buildNewestItemsLoader = (db, loaders) => + new DataLoader(async (keys) => { + // Essentially, I want to provide the loader-like API, and populate other + // loaders, even though there's only one query to run. + if (keys.length !== 1 && keys[0] !== "all-newest") { + throw new Error( + `this loader can only be loaded with the key "all-newest"` + ); + } + + const [rows, _] = await db.execute( + `SELECT * FROM items ORDER BY created_at DESC LIMIT 20;` + ); + + const entities = rows.map(normalizeRow); + + for (const entity of entities) { + loaders.itemLoader.prime(entity.id, entity); + } + + return [entities]; + }); + let lastKnownUpdate = "1970-01-01"; // start it out very old! let lastResult = new Map(); const buildItemsThatNeedModelsLoader = (db) => @@ -1135,6 +1158,7 @@ function buildLoaders(db) { loaders.itemByNameLoader = buildItemByNameLoader(db, loaders); loaders.itemSearchLoader = buildItemSearchLoader(db, loaders); loaders.itemSearchToFitLoader = buildItemSearchToFitLoader(db, loaders); + loaders.newestItemsLoader = buildNewestItemsLoader(db, loaders); loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db); loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader( db diff --git a/src/server/types/Item.js b/src/server/types/Item.js index f049676..8965800 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -122,6 +122,9 @@ const typeDefs = gql` limit: Int ): ItemSearchResult! + # Get the 20 items most recently added to our database. Cache for 1 hour. + newestItems: [Item!]! @cacheControl(maxAge: 3600) + # Get items that need models for the given color. # # NOTE: Most color IDs won't be accepted here. Either pass the ID of a @@ -386,6 +389,10 @@ const resolvers = { const zones = zoneIds.map((id) => ({ id })); return { query, zones, items }; }, + newestItems: async (_, __, { newestItemsLoader }) => { + const items = await newestItemsLoader.load("all-newest"); + return items.map((item) => ({ id: item.id })); + }, itemsThatNeedModels: async ( _, { colorId = "8" }, // Defaults to Blue