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,
|
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");
|
||||||
|
|
|
@ -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
|
||||||
|
|
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;
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue