Allow infinite scrolling of coaches.

main
Joshua Potter 2023-12-05 19:46:27 -07:00
parent 2a4d969030
commit 2660cac8a8
9 changed files with 139 additions and 55 deletions

View File

@ -1,10 +1,8 @@
import * as React from "react" import * as React from "react"
import { motion, useReducedMotion } from "framer-motion" import { MotionProps, motion, useReducedMotion } from "framer-motion"
const FadeInStaggerContext = React.createContext(false) const FadeInStaggerContext = React.createContext(false)
const viewport = { once: true, margin: "0px 0px -200px" }
export function FadeIn({ ...props }) { export function FadeIn({ ...props }) {
let shouldReduceMotion = useReducedMotion() let shouldReduceMotion = useReducedMotion()
let isInStaggerGroup = React.useContext(FadeInStaggerContext) let isInStaggerGroup = React.useContext(FadeInStaggerContext)
@ -21,23 +19,42 @@ export function FadeIn({ ...props }) {
: { : {
initial: "hidden", initial: "hidden",
whileInView: "visible", whileInView: "visible",
viewport, viewport: {
once: true,
margin: "0px 0px -200px",
},
})} })}
{...props} {...props}
/> />
) )
} }
export function FadeInStagger({ faster = false, ...props }) { export type FadeInStaggerProps = {
faster?: boolean
className?: string
} & MotionProps
export const FadeInStagger = React.forwardRef(function FadeInStagger(
props: FadeInStaggerProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { faster = false, ...other } = props
// Consider dropping framer-motion:
// https://github.com/framer/motion/issues/776
return ( return (
<FadeInStaggerContext.Provider value={true}> <FadeInStaggerContext.Provider value={true}>
<motion.div <motion.div
ref={ref}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
viewport={viewport} viewport={{
once: true,
margin: "0px 0px -200px",
}}
transition={{ staggerChildren: faster ? 0.12 : 0.2 }} transition={{ staggerChildren: faster ? 0.12 : 0.2 }}
{...props} {...other}
/> />
</FadeInStaggerContext.Provider> </FadeInStaggerContext.Provider>
) )
} })

View File

@ -3,10 +3,10 @@ import clsx from "clsx"
import { FadeIn } from "./FadeIn" import { FadeIn } from "./FadeIn"
export type FallbackMessageProps = React.ComponentPropsWithoutRef<"div"> & { export type FallbackMessageProps = {
title: string title: string
body: string body: string
} } & React.ComponentPropsWithoutRef<"div">
export const FallbackMessage = React.forwardRef(function FallbackMessage( export const FallbackMessage = React.forwardRef(function FallbackMessage(
props: FallbackMessageProps, props: FallbackMessageProps,

View File

@ -4,7 +4,7 @@ import { SelectProps } from "@mui/base/Select"
import type { Language } from "../types/Language" import type { Language } from "../types/Language"
import { Select, Option } from "./Select" import { Select, Option } from "./Select"
import { useFetchLanguages } from "../utils/queries" import { useLanguagesQuery } from "../utils/queries"
export type SelectLanguageProps = SelectProps<{}, boolean> export type SelectLanguageProps = SelectProps<{}, boolean>
@ -15,7 +15,7 @@ export const SelectLanguage = React.forwardRef(function SelectLanguage(
const id = React.useId() const id = React.useId()
const [options, setOptions] = React.useState<Language[] | null>(null) const [options, setOptions] = React.useState<Language[] | null>(null)
const { defaultValue, ...other } = props const { defaultValue, ...other } = props
const { isLoading, data } = useFetchLanguages() const { isLoading, data } = useLanguagesQuery()
React.useEffect(() => { React.useEffect(() => {
if (data) { if (data) {

View File

@ -1,17 +1,39 @@
import * as React from "react" import * as React from "react"
import type { SearchParams } from "../types/SearchParams"
import { Container } from "../components/Container" import { Container } from "../components/Container"
import { FadeIn, FadeInStagger } from "../components/FadeIn" import { FadeIn } from "../components/FadeIn"
import { FallbackMessage } from "../components/FallbackMessage" import { FallbackMessage } from "../components/FallbackMessage"
import { FilterModal } from "../components/FilterModal" import { FilterModal } from "../components/FilterModal"
import { FilterScroll } from "../components/FilterScroll" import { FilterScroll } from "../components/FilterScroll"
import { Loading } from "../components/Loading" import { Loading } from "../components/Loading"
import { SearchResult } from "../components/SearchResult" import { SearchResult } from "../components/SearchResult"
import { defaultSearchParams } from "../types/SearchParams" import { defaultSearchParams } from "../types/SearchParams"
import { useFetchCoaches } from "../utils/queries" import { useCoachesInfiniteQuery } from "../utils/queries"
function SearchResults() { function SearchResults({ searchParams }: { searchParams: SearchParams }) {
const { isLoading, isError, data } = useFetchCoaches() const { isLoading, isError, data, fetchNextPage, hasNextPage, isFetching } =
useCoachesInfiniteQuery(searchParams)
const resultsRef = React.useRef<HTMLDivElement | null>(null)
const handleScroll = React.useCallback(() => {
if (!resultsRef.current) {
return
}
const resultRect = resultsRef.current.getBoundingClientRect()
if (!isFetching && hasNextPage && resultRect.bottom < 800) {
fetchNextPage()
}
}, [isFetching, hasNextPage, resultsRef])
React.useEffect(() => {
window.addEventListener("scroll", handleScroll, { passive: true })
return () => {
window.removeEventListener("scroll", handleScroll)
}
}, [isFetching, hasNextPage, resultsRef])
if (isLoading) { if (isLoading) {
return <Loading className="mt-40" loading /> return <Loading className="mt-40" loading />
@ -28,28 +50,38 @@ function SearchResults() {
} }
return ( return (
<FadeInStagger <>
className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" <div
faster ref={resultsRef}
> className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
{data?.map((coach, index) => ( >
<FadeIn key={index} className="flex cursor-pointer flex-col"> {data?.pages.map((group, index) => (
<SearchResult <React.Fragment key={index}>
src={coach.image_url ?? ""} {group.map((coach) => (
title={coach.name ?? ""} <FadeIn
subtitle={coach.name ?? ""} key={`${coach.site}-${coach.username}`}
target="_blank" className="flex cursor-pointer flex-col"
href={ >
coach.site === "lichess" <SearchResult
? `https://lichess.org/coach/${coach.username}` src={coach.image_url ?? ""}
: coach.site === "chesscom" title={coach.name ?? ""}
? `https://www.chess.com/member/${coach.username}` subtitle={coach.name ?? ""}
: undefined target="_blank"
} href={
/> coach.site === "lichess"
</FadeIn> ? `https://lichess.org/coach/${coach.username}`
))} : coach.site === "chesscom"
</FadeInStagger> ? `https://www.chess.com/member/${coach.username}`
: undefined
}
/>
</FadeIn>
))}
</React.Fragment>
))}
</div>
{hasNextPage ? <Loading className="pt-20" loading /> : null}
</>
) )
} }
@ -73,7 +105,7 @@ export function Search() {
setModalOpen(false) setModalOpen(false)
}} }}
/> />
<SearchResults /> <SearchResults searchParams={searchParams} />
</Container> </Container>
) )
} }

View File

@ -1,22 +1,35 @@
import axios from "axios" import axios from "axios"
import { useQuery } from "@tanstack/react-query" import { useQuery, useInfiniteQuery } from "@tanstack/react-query"
import type { Coach } from "../types/Coach" import type { Coach } from "../types/Coach"
import type { Language } from "../types/Language" import type { Language } from "../types/Language"
import type { SearchParams } from "../types/SearchParams"
export const useFetchCoaches = () => { export const useCoachesInfiniteQuery = (searchParams: SearchParams) => {
return useQuery({ return useInfiniteQuery({
queryKey: ["api", "coaches"], queryKey: ["coaches", searchParams],
queryFn: async () => { queryFn: async ({ pageParam = 1 }) => {
const response = await axios.get<{ data: Coach[] }>("/api/coaches/") const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
params: {
page_no: pageParam,
page_size: 15,
},
})
return response.data.data return response.data.data
}, },
initialPageParam: 1,
getNextPageParam: (lastPage, _pages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
}) })
} }
export const useFetchLanguages = () => { export const useLanguagesQuery = () => {
return useQuery({ return useQuery({
queryKey: ["api", "languages"], queryKey: ["languages"],
queryFn: async () => { queryFn: async () => {
const response = await axios.get<{ data: Language[] }>("/api/languages/") const response = await axios.get<{ data: Language[] }>("/api/languages/")
return response.data.data return response.data.data

View File

@ -11,17 +11,20 @@ defmodule BoardWise.Coaches do
@prefix "coach_scraper" @prefix "coach_scraper"
@doc """ @doc """
Returns the list of coaches. Return the list of coaches at the given page, based on page size.
## Examples ## Examples
iex> list_coaches() iex> page_coaches(1, 25)
[%Coach{}, ...] [%Coach{}, ...]
""" """
def list_coaches do def page_coaches(page_no, page_size) do
offset = (page_no - 1) * page_size
Coach Coach
|> limit(6) |> limit(^page_size)
|> offset(^offset)
|> Repo.all(prefix: @prefix) |> Repo.all(prefix: @prefix)
end end

View File

@ -33,6 +33,7 @@ defmodule BoardWise.Coaches.Coach do
:username, :username,
:name, :name,
:image_url, :image_url,
:languages,
:rapid, :rapid,
:blitz, :blitz,
:bullet :bullet

View File

@ -1,10 +1,28 @@
defmodule BoardWiseWeb.CoachController do defmodule BoardWiseWeb.CoachController do
use BoardWiseWeb, :controller use BoardWiseWeb, :controller
require Logger
alias BoardWise.Coaches alias BoardWise.Coaches
def index(conn, _params) do plug :fetch_query_params
coaches = Coaches.list_coaches()
def index(conn, params) do
page_no = get_integer_param(params, "page_no", 1)
page_size = get_integer_param(params, "page_size", 10)
coaches = Coaches.page_coaches(page_no, page_size)
render(conn, :index, coaches: coaches) render(conn, :index, coaches: coaches)
end end
defp get_integer_param(params, key, default) do
val = Map.get(params, key)
if is_nil(val) do
default
else
case Integer.parse(val) do
{parsed, ""} -> parsed
_ -> default
end
end
end
end end

View File

@ -10,9 +10,9 @@ defmodule BoardWise.CoachesTest do
@invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil} @invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil}
test "list_coaches/0 returns all coaches" do test "page_coaches/2 returns all coaches" do
coach = coach_fixture() coach = coach_fixture()
assert Coaches.list_coaches() == [coach] assert Coaches.page_coaches(1, 1000) == [coach]
end end
test "get_coach!/1 returns the coach with given id" do test "get_coach!/1 returns the coach with given id" do