Add Latest Items section to homepage

This commit is contained in:
Emi Matchu 2021-01-18 06:31:27 -08:00
parent 934dd829c6
commit 334d89c101
5 changed files with 239 additions and 64 deletions

View file

@ -5,6 +5,7 @@ import {
Box, Box,
Button, Button,
Flex, Flex,
HStack,
Input, Input,
Textarea, Textarea,
useColorModeValue, useColorModeValue,
@ -13,16 +14,21 @@ import {
VStack, VStack,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { useLazyQuery } from "@apollo/client"; import { useLazyQuery, useQuery } from "@apollo/client";
import { import {
ErrorMessage,
Heading1, Heading1,
Heading2,
useCommonStyles, useCommonStyles,
useLocalStorage, useLocalStorage,
usePageTitle, usePageTitle,
} from "./util"; } from "./util";
import OutfitPreview from "./components/OutfitPreview"; import OutfitPreview from "./components/OutfitPreview";
import SpeciesColorPicker from "./components/SpeciesColorPicker"; import SpeciesColorPicker from "./components/SpeciesColorPicker";
import SquareItemCard, {
SquareItemCardSkeleton,
} from "./components/SquareItemCard";
import WIPCallout from "./components/WIPCallout"; import WIPCallout from "./components/WIPCallout";
import HomepageSplashImg from "./images/homepage-splash.png"; import HomepageSplashImg from "./images/homepage-splash.png";
@ -81,6 +87,8 @@ function HomePage() {
<Box height="4" /> <Box height="4" />
<SubmitPetForm /> <SubmitPetForm />
<Box height="16" /> <Box height="16" />
<NewItemsSection />
<Box height="16" />
<FeedbackFormSection /> <FeedbackFormSection />
<Box height="16" /> <Box height="16" />
<WIPCallout details="We started building this last year, but, well… what a year 😅 Anyway, this will eventually become the main site, at impress.openneo.net!"> <WIPCallout details="We started building this last year, but, well… what a year 😅 Anyway, this will eventually become the main site, at impress.openneo.net!">
@ -269,6 +277,85 @@ function SubmitPetForm() {
); );
} }
function NewItemsSection() {
return (
<Box width="100%">
<Heading2 textAlign="left">Latest items</Heading2>
<NewItemsSectionContent />
</Box>
);
}
function NewItemsSectionContent() {
const { loading, error, data } = useQuery(gql`
query NewItemsSection {
newestItems {
id
name
thumbnailUrl
}
}
`);
if (loading) {
return (
<ItemCardHStack>
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton minHeightNumLines={3} />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
<SquareItemCardSkeleton />
</ItemCardHStack>
);
}
if (error) {
return (
<ErrorMessage>
Couldn't load new items. Check your connection and try again!
</ErrorMessage>
);
}
return (
<ItemCardHStack>
{data.newestItems.map((item) => (
<SquareItemCard key={item.id} item={item} />
))}
</ItemCardHStack>
);
}
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...
<Flex maxWidth="100%" overflow="auto" paddingY="4">
<Box minWidth="2" />
<HStack align="flex-start" spacing="4">
{children}
</HStack>
<Box minWidth="2" />
</Flex>
);
}
function FeedbackFormSection() { function FeedbackFormSection() {
const { brightBackground } = useCommonStyles(); const { brightBackground } = useCommonStyles();
const pitchBorderColor = useColorModeValue("gray.300", "green.400"); const pitchBorderColor = useColorModeValue("gray.300", "green.400");

View file

@ -9,81 +9,20 @@ import {
WrapItem, WrapItem,
useColorModeValue, useColorModeValue,
useTheme, useTheme,
useToken,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { CheckIcon, NotAllowedIcon, StarIcon } from "@chakra-ui/icons"; import { CheckIcon, NotAllowedIcon, StarIcon } from "@chakra-ui/icons";
import { HiSparkles } from "react-icons/hi"; import { HiSparkles } from "react-icons/hi";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import SquareItemCard from "./SquareItemCard";
import { safeImageUrl, useCommonStyles } from "../util"; import { safeImageUrl, useCommonStyles } from "../util";
function ItemCard({ item, badges, variant = "list", ...props }) { function ItemCard({ item, badges, variant = "list", ...props }) {
const { brightBackground } = useCommonStyles(); const { brightBackground } = useCommonStyles();
const brightBackgroundValue = useToken("colors", brightBackground);
const theme = useTheme();
switch (variant) { switch (variant) {
case "grid": case "grid":
return ( return <SquareItemCard item={item} {...props} />;
// 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.
<ClassNames>
{({ css }) => (
<Link
to={`/items/${item.id}`}
className={css`
transition: all 0.2s;
&:hover,
&:focus {
transform: scale(1.05);
}
&:focus {
box-shadow: ${theme.shadows.outline};
outline: none;
}
`}
>
<div
className={css`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: ${theme.shadows.md};
border-radius: ${theme.radii.md};
padding: ${theme.space["3"]};
width: calc(80px + 2em);
background: ${brightBackgroundValue};
`}
>
<img
src={safeImageUrl(item.thumbnailUrl)}
alt={`Thumbnail art for ${item.name}`}
width={80}
height={80}
/>
<div
className={css`
/* Set min height to match a 2-line item name, so the cards
* in a row aren't toooo differently sized... */
margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm};
min-height: 2.5em;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
`}
// HACK: Emotion turns this into -webkit-display: -webkit-box?
style={{ display: "-webkit-box" }}
>
{item.name}
</div>
</div>
</Link>
)}
</ClassNames>
);
case "list": case "list":
return ( return (
<Box <Box

View file

@ -0,0 +1,118 @@
import React from "react";
import { Skeleton, useTheme, useToken } from "@chakra-ui/react";
import { ClassNames } from "@emotion/react";
import { Link } from "react-router-dom";
import { safeImageUrl, useCommonStyles } from "../util";
function SquareItemCard({ item, ...props }) {
const theme = useTheme();
return (
<ClassNames>
{({ 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.
<Link
to={`/items/${item.id}`}
className={css`
transition: all 0.2s;
&:hover,
&:focus {
transform: scale(1.05);
}
&:focus {
box-shadow: ${theme.shadows.outline};
outline: none;
}
`}
{...props}
>
<SquareItemCardLayout
name={item.name}
thumbnailImage={
<img
src={safeImageUrl(item.thumbnailUrl)}
alt={`Thumbnail art for ${item.name}`}
width={80}
height={80}
className={css`
border-radius: ${theme.radii.md};
`}
loading="lazy"
/>
}
/>
</Link>
)}
</ClassNames>
);
}
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.
<ClassNames>
{({ css }) => (
<div
className={css`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: ${theme.shadows.md};
border-radius: ${theme.radii.md};
padding: ${theme.space["3"]};
width: calc(80px + 2em);
background: ${brightBackgroundValue};
`}
>
{thumbnailImage}
<div
className={css`
margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm};
/* Set min height to match a 2-line item name, so the cards
* in a row aren't toooo differently sized... */
min-height: ${minHeightNumLines * 1.5 + "em"};
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
`}
// HACK: Emotion turns this into -webkit-display: -webkit-box?
style={{ display: "-webkit-box" }}
>
{name}
</div>
</div>
)}
</ClassNames>
);
}
export function SquareItemCardSkeleton({ minHeightNumLines }) {
return (
<SquareItemCardLayout
name={
<>
<Skeleton width="100%" height="1em" marginTop="2" />
{minHeightNumLines >= 3 && (
<Skeleton width="100%" height="1em" marginTop="2" />
)}
</>
}
thumbnailImage={<Skeleton width="80px" height="80px" />}
minHeightNumLines={minHeightNumLines}
/>
);
}
export default SquareItemCard;

View file

@ -376,6 +376,29 @@ const buildItemSearchToFitLoader = (db, loaders) =>
return responses; 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 lastKnownUpdate = "1970-01-01"; // start it out very old!
let lastResult = new Map(); let lastResult = new Map();
const buildItemsThatNeedModelsLoader = (db) => const buildItemsThatNeedModelsLoader = (db) =>
@ -1135,6 +1158,7 @@ function buildLoaders(db) {
loaders.itemByNameLoader = buildItemByNameLoader(db, loaders); loaders.itemByNameLoader = buildItemByNameLoader(db, loaders);
loaders.itemSearchLoader = buildItemSearchLoader(db, loaders); loaders.itemSearchLoader = buildItemSearchLoader(db, loaders);
loaders.itemSearchToFitLoader = buildItemSearchToFitLoader(db, loaders); loaders.itemSearchToFitLoader = buildItemSearchToFitLoader(db, loaders);
loaders.newestItemsLoader = buildNewestItemsLoader(db, loaders);
loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db); loaders.itemsThatNeedModelsLoader = buildItemsThatNeedModelsLoader(db);
loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader( loaders.itemBodiesWithAppearanceDataLoader = buildItemBodiesWithAppearanceDataLoader(
db db

View file

@ -122,6 +122,9 @@ const typeDefs = gql`
limit: Int limit: Int
): ItemSearchResult! ): 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. # Get items that need models for the given color.
# #
# NOTE: Most color IDs won't be accepted here. Either pass the ID of a # 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 })); const zones = zoneIds.map((id) => ({ id }));
return { query, zones, items }; return { query, zones, items };
}, },
newestItems: async (_, __, { newestItemsLoader }) => {
const items = await newestItemsLoader.load("all-newest");
return items.map((item) => ({ id: item.id }));
},
itemsThatNeedModels: async ( itemsThatNeedModels: async (
_, _,
{ colorId = "8" }, // Defaults to Blue { colorId = "8" }, // Defaults to Blue