Allow infinite scrolling of coaches.
parent
2a4d969030
commit
2660cac8a8
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ defmodule BoardWise.Coaches.Coach do
|
||||||
:username,
|
:username,
|
||||||
:name,
|
:name,
|
||||||
:image_url,
|
:image_url,
|
||||||
|
:languages,
|
||||||
:rapid,
|
:rapid,
|
||||||
:blitz,
|
:blitz,
|
||||||
:bullet
|
:bullet
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue