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

View File

@ -9,6 +9,21 @@ body {
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 * {
font-family: "Marcellus";
}
@ -50,16 +65,9 @@ header {
}
}
.cardbox {
display: flex;
flex-wrap: wrap;
flex-direction: row;
font-size: 1.4rem;
justify-content: space-between;
align-content: space-between;
.card {
width: 192px;
height: 540px;
border: 1px solid #707c80;
border-radius: 8px;
padding: 0.5rem;
@ -72,7 +80,11 @@ header {
.card-text {
line-height: 1.4;
font-size: 1.4rem;
}
.content table td {
font-size: 1.4rem;
}
}

View File

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

View File

@ -2,7 +2,9 @@ import * as React from "react";
import { useState, useEffect, useRef } from "react";
import { Loading } from "./Loading";
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 {
CardProps,
CardRequestProps,
@ -16,54 +18,85 @@ const firstUrl = `https://api.elderscrollslegends.io/v1/cards?pageSize=${PAGE_SI
const emptyCards: CardState = {
cards: [],
next: firstUrl
next: firstUrl,
maxCards: -1
};
export const Cards = () => {
const [cards, setCards]: CardStateHandler = useState(emptyCards);
const [cardPage, setCardPage]: [string | null, Function] = useState(null);
const [loading, setLoading]: [boolean, Function] = useState(false);
const [currentCard, setCurrentCard]: [CardProps | null, Function] = useState(
null
);
const getNextPage = () => {
setCardPage(cards.next);
setLoading(true);
};
let pageBottomRef = useRef(null);
useInfiniteScroll(pageBottomRef, getNextPage);
const fetchMoreCards = async () => {
if (cards.next === null || cardPage === null) {
return cards;
const loadItems = () => {
if (cards.next === null || cards.cards.length === cards.maxCards) {
return;
}
const request = new Request(cardPage!);
const response = await fetch(request);
const data: CardRequestProps = await response.json();
setLoading(true);
const request = new Request(cards.next);
fetch(request)
.then(response => response.json())
.then((data: CardRequestProps) => {
setCards({
cards: cards.cards.concat(data.cards),
next: data._links && data._links.next ? data._links.next : null
cards: [...cards.cards, ...data.cards],
next: data._links && data._links.next ? data._links.next : null,
maxcards: data._totalCount
});
setLoading(false);
});
};
if (cards.next && cardPage === null) {
getNextPage();
const loadMoreCards = (options: any) => {
if (options.startLoading !== null) {
options.startLoading();
}
loadItems();
};
useEffect(() => {
fetchMoreCards();
}, [cardPage]);
const onLayoutComplete = (options: any) => {
!options.isLayout && options.endLoading();
};
const handleCloseCard = () => {
setCurrentCard(null);
};
const handleOpenCard = (id: string) => {
const card = cards.cards.find(card => card.id === id);
if (card) {
setCurrentCard(card);
}
};
return (
<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) => (
<Card card={card} key={count} />
<Card card={card} key={count} onClick={handleOpenCard} />
))}
</div>
{loading && <Loading />}
<div id="page-bottom" ref={pageBottomRef} />
</GridLayout>
{loading ? <Loading /> : ""}
</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;
type: string;
cost: number;
power: number;
health: number;
set: SetProps;
collectible: boolean;
soulSummon: number;
soulTrap: number;
text: string;
subtypes: string[];
attributes: string[];
keywords: string[];
unique: boolean;
@ -33,11 +36,10 @@ export interface CardRequestProps {
_totalCount: number;
}
export interface CardState {
cards: CardProps[];
next: string | null;
maxCards: number;
}
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"
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":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -1671,6 +1705,13 @@
dependencies:
"@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":
version "16.9.49"
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"
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:
version "0.1.2"
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@ -8856,7 +8902,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
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"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
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"
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:
version "3.4.3"
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:
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:
version "2.0.0"
resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0"