Add Latest Items section to homepage
This commit is contained in:
parent
934dd829c6
commit
334d89c101
5 changed files with 239 additions and 64 deletions
|
@ -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() {
|
|||
<Box height="4" />
|
||||
<SubmitPetForm />
|
||||
<Box height="16" />
|
||||
<NewItemsSection />
|
||||
<Box height="16" />
|
||||
<FeedbackFormSection />
|
||||
<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!">
|
||||
|
@ -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() {
|
||||
const { brightBackground } = useCommonStyles();
|
||||
const pitchBorderColor = useColorModeValue("gray.300", "green.400");
|
||||
|
|
|
@ -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.
|
||||
<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>
|
||||
);
|
||||
return <SquareItemCard item={item} {...props} />;
|
||||
case "list":
|
||||
return (
|
||||
<Box
|
||||
|
|
118
src/app/components/SquareItemCard.js
Normal file
118
src/app/components/SquareItemCard.js
Normal 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;
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue