FEAT Using a library for infinite scroll.

This is better.  Always nice to let the professionals do all the hard
lifting for ya when you're pressed for time.

Also: Incorporated a pretty nice little modal pop-up.  Styling sucks (at
the moment), but I can fix that later.
This commit is contained in:
Elf M. Sternberg 2020-09-16 10:59:40 -07:00
parent 642e7e9f2f
commit 73e310ea14
8 changed files with 300 additions and 95 deletions

View File

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@egjs/react-infinitegrid": "^3.0.5",
"@testing-library/jest-dom": "^4.2.4", "@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2", "@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2", "@testing-library/user-event": "^7.1.2",
@ -13,6 +14,7 @@
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-modal": "^3.11.2",
"react-scripts": "3.4.3", "react-scripts": "3.4.3",
"sass": "^1.26.10", "sass": "^1.26.10",
"typescript": "^3.9.3" "typescript": "^3.9.3"
@ -38,6 +40,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/react-modal": "^3.10.6",
"http-server": "^0.12.3" "http-server": "^0.12.3"
} }
} }

View File

@ -9,6 +9,21 @@ body {
font-family: "Marcellus"; font-family: "Marcellus";
} }
.current-card {
background: repeat url("images/elder_scrolling_bg.png") #0c0c0c;
color: #f8f8f8;
}
.fullcard tr td {
padding-right: 2rem;
}
.fullcard tr td:nth-child(3) {
padding-left: 8rem;
}
body * { body * {
font-family: "Marcellus"; font-family: "Marcellus";
} }
@ -50,32 +65,29 @@ header {
} }
} }
.cardbox { .card {
display: flex; width: 192px;
flex-wrap: wrap; height: 540px;
flex-direction: row; border: 1px solid #707c80;
font-size: 1.4rem; border-radius: 8px;
justify-content: space-between; padding: 0.5rem;
align-content: space-between; margin: 0.5rem;
img {
height: 290px;
width: 175px;
}
.card-text {
line-height: 1.4;
font-size: 1.4rem;
}
.card { .content table td {
width: 192px; font-size: 1.4rem;
border: 1px solid #707c80;
border-radius: 8px;
padding: 0.5rem;
margin: 0.5rem;
img {
height: 290px;
width: 175px;
}
.card-text {
line-height: 1.4;
}
} }
} }
.lds-spinner-container { .lds-spinner-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -1,9 +1,19 @@
import * as React from "react"; import * as React from "react";
import { CardProps } from "./types"; import { CardProps } from "./types";
export const Card = ({ card }: { card: CardProps }) => { export const Card = ({
card,
onClick
}: {
card: CardProps;
onClick: Function | null;
}) => {
return ( return (
<div className="card" key="{idx}"> <div
className="card"
key="{idx}"
onClick={_event => onClick && onClick(card.id)}
>
<img src={card.imageUrl} alt={card.name} /> <img src={card.imageUrl} alt={card.name} />
<div className="content"> <div className="content">
<h4>{card.name}</h4> <h4>{card.name}</h4>
@ -28,4 +38,3 @@ export const Card = ({ card }: { card: CardProps }) => {
</div> </div>
); );
}; };

View File

@ -2,7 +2,9 @@ import * as React from "react";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Loading } from "./Loading"; import { Loading } from "./Loading";
import { Card } from "./Card"; import { Card } from "./Card";
import { useInfiniteScroll } from "./infiniteScroll"; import { JustifiedLayout, GridLayout } from "@egjs/react-infinitegrid";
import ReactModal from "react-modal";
import { FullCard, fullcardStyles } from "./FullCard";
import { import {
CardProps, CardProps,
CardRequestProps, CardRequestProps,
@ -16,54 +18,85 @@ const firstUrl = `https://api.elderscrollslegends.io/v1/cards?pageSize=${PAGE_SI
const emptyCards: CardState = { const emptyCards: CardState = {
cards: [], cards: [],
next: firstUrl next: firstUrl,
maxCards: -1
}; };
export const Cards = () => { export const Cards = () => {
const [cards, setCards]: CardStateHandler = useState(emptyCards); const [cards, setCards]: CardStateHandler = useState(emptyCards);
const [cardPage, setCardPage]: [string | null, Function] = useState(null);
const [loading, setLoading]: [boolean, Function] = useState(false); const [loading, setLoading]: [boolean, Function] = useState(false);
const [currentCard, setCurrentCard]: [CardProps | null, Function] = useState(
null
);
const getNextPage = () => { const loadItems = () => {
setCardPage(cards.next); if (cards.next === null || cards.cards.length === cards.maxCards) {
setLoading(true); return;
};
let pageBottomRef = useRef(null);
useInfiniteScroll(pageBottomRef, getNextPage);
const fetchMoreCards = async () => {
if (cards.next === null || cardPage === null) {
return cards;
} }
const request = new Request(cardPage!); setLoading(true);
const response = await fetch(request); const request = new Request(cards.next);
const data: CardRequestProps = await response.json(); fetch(request)
setCards({ .then(response => response.json())
cards: cards.cards.concat(data.cards), .then((data: CardRequestProps) => {
next: data._links && data._links.next ? data._links.next : null setCards({
}); cards: [...cards.cards, ...data.cards],
setLoading(false); next: data._links && data._links.next ? data._links.next : null,
maxcards: data._totalCount
});
setLoading(false);
});
}; };
if (cards.next && cardPage === null) { const loadMoreCards = (options: any) => {
getNextPage(); if (options.startLoading !== null) {
} options.startLoading();
}
loadItems();
};
useEffect(() => { const onLayoutComplete = (options: any) => {
fetchMoreCards(); !options.isLayout && options.endLoading();
}, [cardPage]); };
const handleCloseCard = () => {
setCurrentCard(null);
};
const handleOpenCard = (id: string) => {
const card = cards.cards.find(card => card.id === id);
if (card) {
setCurrentCard(card);
}
};
return ( return (
<div className="container"> <div className="container">
<div className="cardbox"> <ReactModal
isOpen={currentCard !== null}
style={fullcardStyles}
contentLabel=""
onRequestClose={handleCloseCard}
>
<FullCard card={currentCard} />
</ReactModal>
<GridLayout
options={{
isConstantSize: true,
isEqualSize: true,
transitionDuration: 0.2
}}
layoutOptions={{
margin: 10
}}
onAppend={loadMoreCards}
onLayoutComplete={onLayoutComplete}
>
{cards.cards.map((card: CardProps, count: number) => ( {cards.cards.map((card: CardProps, count: number) => (
<Card card={card} key={count} /> <Card card={card} key={count} onClick={handleOpenCard} />
))} ))}
</div> </GridLayout>
{loading && <Loading />} {loading ? <Loading /> : ""}
<div id="page-bottom" ref={pageBottomRef} />
</div> </div>
); );
}; };

111
src/cards/FullCard.tsx Normal file
View File

@ -0,0 +1,111 @@
import * as React from "react";
import { CardProps } from "./types";
export const fullcardStyles = {
content: {
backgroundColor: "#0c0c0c",
color: "#f8f8f8"
}
};
export const FullCard = ({ card }: { card: CardProps | null }) => {
if (card === null) {
return <></>;
}
return (
<div className="fullcard">
<img src={card.imageUrl} alt={card.name} />
<div className="content">
<h4>{card.name}</h4>
<p className="card-text">{card.text}</p>
<table>
<tbody>
<tr>
<td>
<strong>Type</strong>
</td>
<td>{card.type}</td>
<td>
<strong>Cost</strong>
</td>
<td>{card.cost}</td>
</tr>
<tr>
<td>
<strong>Subtypes</strong>
</td>
<td>{card.subtypes.join(", ")}</td>
<td>
<strong>Power</strong>
</td>
<td>{card.power}</td>
</tr>
<tr>
<td>
<strong>Rarity</strong>
</td>
<td>{card.rarity}</td>
<td>
<strong>Health</strong>
</td>
<td>{card.health}</td>
</tr>
<tr>
<td>
<strong>Set</strong>
</td>
<td>{card.set.name}</td>
<td>
<strong>Soul Summon</strong>
</td>
<td>{card.soulSummon}</td>
</tr>
<tr>
<td>
<strong>Attributes</strong>
</td>
<td>{card.attributes.join(", ")}</td>
<td>
<strong>Soul Trap</strong>
</td>
<td>{card.soulTrap}</td>
</tr>
<tr>
<td>
<strong>Keywords</strong>
</td>
<td>{card.keywords}</td>
<td />
</tr>
</tbody>
</table>
<table>
<tbody>
<tr>
<td>
<strong>Set Name:</strong>
</td>
<td>{card.set.name}</td>
</tr>
<tr>
<td>
<strong>Type:</strong>
</td>
<td>{card.type}</td>
</tr>
</tbody>
</table>
{card.collectible ? <p>This card is considered collectible.</p> : ""}
{card.unique ? (
<p>
This card is unique. Unique cards may only be used once per deck.
</p>
) : (
""
)}
</div>
</div>
);
};

View File

@ -1,33 +0,0 @@
import * as React from "react";
import { useEffect, useCallback } from "react";
// From the Smashing Magazine article,
// https://www.smashingmagazine.com/2020/03/infinite-scroll-lazy-image-loading-react/
// As this is new to me, I'm going to try to explain it;
// IntersectionObserver takes a callback to be called when it's
// triggred. It's trigged when the node it's been asked to observe
// changes its relationship with another object or, without a
// specified object, with the viewport. When the `intersectionRatio`
// exceed zero, it is _intersecting_ the viewport, i.e. it is now
// visible.
// useCallback is a memoizer; it only updates the contents of the
// function its wrapped if the included function changes, which
// it shouldn't.
export const useInfiniteScroll = (scrollRef: React.RefObject<HTMLElement>, stateUpdate: Function) => {
const scrollObserver = (node: HTMLElement) => {
new IntersectionObserver(entries => {
if (entries.some(en => en.intersectionRatio > 0)) {
stateUpdate();
}
}).observe(node);
};
useEffect(() => {
if (scrollRef.current) {
scrollObserver(scrollRef.current);
}
}, [scrollObserver, scrollRef]);
}

View File

@ -9,11 +9,14 @@ export interface CardProps {
rarity: string; rarity: string;
type: string; type: string;
cost: number; cost: number;
power: number;
health: number;
set: SetProps; set: SetProps;
collectible: boolean; collectible: boolean;
soulSummon: number; soulSummon: number;
soulTrap: number; soulTrap: number;
text: string; text: string;
subtypes: string[];
attributes: string[]; attributes: string[];
keywords: string[]; keywords: string[];
unique: boolean; unique: boolean;
@ -33,11 +36,10 @@ export interface CardRequestProps {
_totalCount: number; _totalCount: number;
} }
export interface CardState { export interface CardState {
cards: CardProps[]; cards: CardProps[];
next: string | null; next: string | null;
maxCards: number;
} }
export type CardStateHandler = [CardState, Function]; export type CardStateHandler = [CardState, Function];

View File

@ -1166,6 +1166,40 @@
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
"@egjs/component@^2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@egjs/component/-/component-2.1.2.tgz#c466a0a6fc6ba2d479814dcbe6ee4643c25a2e5b"
integrity sha512-7tnPiqxbSZ0porzlm0+/O3qZdanMj0zOq0sb17wQXuaRG49XKKKJaO+SacGnZDqf308N5hzJ0m9fZ4+j+VBvXA==
"@egjs/infinitegrid@^3.6.3":
version "3.6.3"
resolved "https://registry.yarnpkg.com/@egjs/infinitegrid/-/infinitegrid-3.6.3.tgz#f12f60b4d7d983a6cc0371735df69991101618cc"
integrity sha512-GELg3eeOjIvk6DkUB61sab2eyV8yJHB2npR7mHmp0rjGf3nJoEBR6A1FqjIrAtT+NjseBxTP2EqYRyI9CwhrIA==
dependencies:
"@egjs/component" "^2.1.2"
"@egjs/lazyloaded" "0.0.2"
"@egjs/list-differ" "^1.0.0"
"@egjs/lazyloaded@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@egjs/lazyloaded/-/lazyloaded-0.0.2.tgz#1658a2188239e1c5e9dee71734830bcca6625388"
integrity sha512-UWgJCDHxsEP/sF+ztwl8F79rP1RHmlwG5ur0fFUT6RgaqKN6aQTnuYm9G/MQB48mWpw/tsY80Ha7xhvhryl+FA==
dependencies:
"@egjs/component" "^2.1.2"
"@egjs/list-differ@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@egjs/list-differ/-/list-differ-1.0.0.tgz#2277aff52e3e4bd9318d5c30ffc3ba3b6216f05e"
integrity sha512-HsbMKc0ZAQH+EUeCmI/2PvTYSybmkaWwakU8QGDYYgMVIg9BQ5sM0A0Nnombjxo2+JzXHxmH+jw//yGX+y6GYw==
"@egjs/react-infinitegrid@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@egjs/react-infinitegrid/-/react-infinitegrid-3.0.5.tgz#be3759b219c67d7f8e487a2857ccd7d453949b26"
integrity sha512-Lf90xlCuThKeg9JfZEd9gdn4a5WgHaL/DmsiS/qWe3dUs7XmEolvfh3tzCP6j8APt6ezQsblr0q/DpUnG/7xkg==
dependencies:
"@egjs/infinitegrid" "^3.6.3"
"@egjs/list-differ" "^1.0.0"
"@hapi/address@2.x.x": "@hapi/address@2.x.x":
version "2.1.4" version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -1671,6 +1705,13 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-modal@^3.10.6":
version "3.10.6"
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.10.6.tgz#76717220f32bc72769190692147814a540053c7e"
integrity sha512-XpshhwVYir1TRZ2HS5EfmNotJjB8UEC2IkT3omNtiQzROOXSzVLz5xsjwEpACP8U+PctkpfZepX+WT5oDf0a9g==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^16.9.49": "@types/react@*", "@types/react@^16.9.49":
version "16.9.49" version "16.9.49"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872"
@ -4528,6 +4569,11 @@ execa@^1.0.0:
signal-exit "^3.0.0" signal-exit "^3.0.0"
strip-eof "^1.0.0" strip-eof "^1.0.0"
exenv@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
exit@^0.1.2: exit@^0.1.2:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@ -8856,7 +8902,7 @@ prompts@^2.0.1:
kleur "^3.0.3" kleur "^3.0.3"
sisteransi "^1.0.4" sisteransi "^1.0.4"
prop-types@^15.6.2, prop-types@^15.7.2: prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -9082,6 +9128,21 @@ react-is@^16.12.0, react-is@^16.8.1, react-is@^16.8.4:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-lifecycles-compat@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-modal@^3.11.2:
version "3.11.2"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4"
integrity sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w==
dependencies:
exenv "^1.2.0"
prop-types "^15.5.10"
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
react-scripts@3.4.3: react-scripts@3.4.3:
version "3.4.3" version "3.4.3"
resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-3.4.3.tgz#21de5eb93de41ee92cd0b85b0e1298d0bb2e6c51" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-3.4.3.tgz#21de5eb93de41ee92cd0b85b0e1298d0bb2e6c51"
@ -10990,6 +11051,13 @@ walker@^1.0.7, walker@~1.0.5:
dependencies: dependencies:
makeerror "1.0.x" makeerror "1.0.x"
warning@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack-chokidar2@^2.0.0: watchpack-chokidar2@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0"