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 { motion, useReducedMotion } from "framer-motion"
import { MotionProps, motion, useReducedMotion } from "framer-motion"
const FadeInStaggerContext = React.createContext(false)
const viewport = { once: true, margin: "0px 0px -200px" }
export function FadeIn({ ...props }) {
let shouldReduceMotion = useReducedMotion()
let isInStaggerGroup = React.useContext(FadeInStaggerContext)
@ -21,23 +19,42 @@ export function FadeIn({ ...props }) {
: {
initial: "hidden",
whileInView: "visible",
viewport,
viewport: {
once: true,
margin: "0px 0px -200px",
},
})}
{...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 (
<FadeInStaggerContext.Provider value={true}>
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={viewport}
viewport={{
once: true,
margin: "0px 0px -200px",
}}
transition={{ staggerChildren: faster ? 0.12 : 0.2 }}
{...props}
{...other}
/>
</FadeInStaggerContext.Provider>
)
}
})

View File

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

View File

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

View File

@ -1,17 +1,39 @@
import * as React from "react"
import type { SearchParams } from "../types/SearchParams"
import { Container } from "../components/Container"
import { FadeIn, FadeInStagger } from "../components/FadeIn"
import { FadeIn } from "../components/FadeIn"
import { FallbackMessage } from "../components/FallbackMessage"
import { FilterModal } from "../components/FilterModal"
import { FilterScroll } from "../components/FilterScroll"
import { Loading } from "../components/Loading"
import { SearchResult } from "../components/SearchResult"
import { defaultSearchParams } from "../types/SearchParams"
import { useFetchCoaches } from "../utils/queries"
import { useCoachesInfiniteQuery } from "../utils/queries"
function SearchResults() {
const { isLoading, isError, data } = useFetchCoaches()
function SearchResults({ searchParams }: { searchParams: SearchParams }) {
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) {
return <Loading className="mt-40" loading />
@ -28,28 +50,38 @@ function SearchResults() {
}
return (
<FadeInStagger
className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
faster
>
{data?.map((coach, index) => (
<FadeIn key={index} className="flex cursor-pointer flex-col">
<SearchResult
src={coach.image_url ?? ""}
title={coach.name ?? ""}
subtitle={coach.name ?? ""}
target="_blank"
href={
coach.site === "lichess"
? `https://lichess.org/coach/${coach.username}`
: coach.site === "chesscom"
? `https://www.chess.com/member/${coach.username}`
: undefined
}
/>
</FadeIn>
))}
</FadeInStagger>
<>
<div
ref={resultsRef}
className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{data?.pages.map((group, index) => (
<React.Fragment key={index}>
{group.map((coach) => (
<FadeIn
key={`${coach.site}-${coach.username}`}
className="flex cursor-pointer flex-col"
>
<SearchResult
src={coach.image_url ?? ""}
title={coach.name ?? ""}
subtitle={coach.name ?? ""}
target="_blank"
href={
coach.site === "lichess"
? `https://lichess.org/coach/${coach.username}`
: coach.site === "chesscom"
? `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)
}}
/>
<SearchResults />
<SearchResults searchParams={searchParams} />
</Container>
)
}

View File

@ -1,22 +1,35 @@
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 { Language } from "../types/Language"
import type { SearchParams } from "../types/SearchParams"
export const useFetchCoaches = () => {
return useQuery({
queryKey: ["api", "coaches"],
queryFn: async () => {
const response = await axios.get<{ data: Coach[] }>("/api/coaches/")
export const useCoachesInfiniteQuery = (searchParams: SearchParams) => {
return useInfiniteQuery({
queryKey: ["coaches", searchParams],
queryFn: async ({ pageParam = 1 }) => {
const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
params: {
page_no: pageParam,
page_size: 15,
},
})
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({
queryKey: ["api", "languages"],
queryKey: ["languages"],
queryFn: async () => {
const response = await axios.get<{ data: Language[] }>("/api/languages/")
return response.data.data

View File

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

View File

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

View File

@ -1,10 +1,28 @@
defmodule BoardWiseWeb.CoachController do
use BoardWiseWeb, :controller
require Logger
alias BoardWise.Coaches
def index(conn, _params) do
coaches = Coaches.list_coaches()
plug :fetch_query_params
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)
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

View File

@ -10,9 +10,9 @@ defmodule BoardWise.CoachesTest do
@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()
assert Coaches.list_coaches() == [coach]
assert Coaches.page_coaches(1, 1000) == [coach]
end
test "get_coach!/1 returns the coach with given id" do