Compare commits
24 Commits
add-langua
...
main
Author | SHA1 | Date |
---|---|---|
Joshua Potter | 0d5a66c604 | |
Joshua Potter | ef264d6670 | |
Joshua Potter | 4d13cf9056 | |
Joshua Potter | db73e3b4f0 | |
Joshua Potter | 0c7d2b5932 | |
Joshua Potter | c605a09c56 | |
Joshua Potter | 0eca8e5f5f | |
Joshua Potter | 2e7efa5c49 | |
Joshua Potter | 8d7a2e4853 | |
Joshua Potter | c54f832935 | |
Joshua Potter | 75e915e71c | |
Joshua Potter | eb812d8366 | |
Joshua Potter | 8a3320e279 | |
Joshua Potter | 286b3dd31d | |
Joshua Potter | e927eab560 | |
Joshua Potter | a75abbe67d | |
Joshua Potter | c6538c884b | |
Joshua Potter | 283bf59546 | |
Joshua Potter | 2660cac8a8 | |
Joshua Potter | 2a4d969030 | |
Joshua Potter | 54c7d14669 | |
Joshua Potter | e43009f166 | |
Joshua Potter | 5691052a97 | |
Joshua Potter | a83d54f6a2 |
|
@ -0,0 +1,37 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
type BorderProps<T extends React.ElementType> = {
|
||||
as?: T
|
||||
className?: string
|
||||
position?: "top" | "left"
|
||||
invert?: boolean
|
||||
}
|
||||
|
||||
export function Border<T extends React.ElementType = "div">({
|
||||
as,
|
||||
className,
|
||||
position = "top",
|
||||
invert = false,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, keyof BorderProps<T>> &
|
||||
BorderProps<T>) {
|
||||
let Component = as ?? "div"
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
"relative before:absolute after:absolute",
|
||||
invert
|
||||
? "before:bg-white after:bg-white/10"
|
||||
: "before:bg-neutral-950 after:bg-neutral-950/10",
|
||||
position === "top" &&
|
||||
"before:left-0 before:top-0 before:h-px before:w-6 after:left-8 after:right-0 after:top-0 after:h-px",
|
||||
position === "left" &&
|
||||
"before:left-0 before:top-0 before:h-6 before:w-px after:bottom-0 after:left-0 after:top-8 after:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
import {
|
||||
Input as BaseInput,
|
||||
InputOwnerState,
|
||||
InputProps,
|
||||
MultiLineInputProps,
|
||||
} from "@mui/base/Input"
|
||||
|
||||
import { FieldContext } from "./FieldSet"
|
||||
import { resolveSlotProps } from "../utils/props"
|
||||
|
||||
export type CheckBoxProps = Omit<InputProps, keyof MultiLineInputProps>
|
||||
|
||||
export const CheckBox = React.forwardRef<HTMLInputElement, CheckBoxProps>(
|
||||
function CheckBox(
|
||||
props: CheckBoxProps,
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const fieldContext = React.useContext(FieldContext)
|
||||
const { disabled = fieldContext?.disabled, className, ...other } = props
|
||||
|
||||
const inputSlotProps = (ownerState: InputOwnerState) => {
|
||||
const resolved = resolveSlotProps(props.slotProps?.input, ownerState)
|
||||
return {
|
||||
...resolved,
|
||||
className: clsx(
|
||||
"w-5 h-5 accent-black cursor-pointer -translate-y-[3px]",
|
||||
resolved?.className
|
||||
),
|
||||
style: {
|
||||
color: "black",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseInput
|
||||
ref={ref}
|
||||
{...other}
|
||||
type="checkbox"
|
||||
className={clsx("h-5 w-5", { "opacity-60": disabled }, className)}
|
||||
slotProps={{ input: inputSlotProps }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
|
@ -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 -120px",
|
||||
},
|
||||
})}
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,171 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
|
||||
import { Button } from "./Button"
|
||||
import { Field } from "./FieldSet"
|
||||
import { Input } from "./Input"
|
||||
import { Label } from "./Label"
|
||||
import { Modal } from "./Modal"
|
||||
import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
|
||||
import { Slider } from "./Slider"
|
||||
|
||||
import {
|
||||
FIDE_RATING_MIN,
|
||||
FIDE_RATING_MAX,
|
||||
SearchParams,
|
||||
} from "../types/SearchParams"
|
||||
|
||||
const computeStepLabels = (
|
||||
min: number,
|
||||
max: number,
|
||||
// The number of labels (+ 1) that should be produced.
|
||||
steps: number,
|
||||
// To which value numbers should be rounded to.
|
||||
round: number
|
||||
) => {
|
||||
let labels = []
|
||||
const delta = Math.floor((max - min) / steps)
|
||||
for (let i = min; i <= max; i += delta) {
|
||||
if (i % round <= round / 2) {
|
||||
labels.push(i - (i % round))
|
||||
} else {
|
||||
labels.push(i + round - (i % round))
|
||||
}
|
||||
}
|
||||
|
||||
labels[labels.length - 1] = max
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
interface FilterModalProps {
|
||||
open: boolean
|
||||
defaultValues: SearchParams
|
||||
onClose: () => void
|
||||
onSubmit: (p: SearchParams) => void
|
||||
}
|
||||
|
||||
export function FilterModal({
|
||||
open,
|
||||
defaultValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: FilterModalProps) {
|
||||
const idPrefix = React.useId()
|
||||
|
||||
const { watch, reset, control, register, setValue, handleSubmit } =
|
||||
useForm<SearchParams>({ defaultValues })
|
||||
|
||||
// Default values are processed immediately despite the modal not being open
|
||||
// at the start. Furthermore, values are preserved after closing and
|
||||
// re-opening the modal, but we want closing the modal to signify canceling.
|
||||
// A simple workaround is to reset everytime we open the modal.
|
||||
React.useEffect(() => reset(defaultValues), [open])
|
||||
|
||||
// Registration
|
||||
|
||||
const proxyLanguages = register("languages")
|
||||
const registerLanguages: Pick<
|
||||
SelectLanguageProps,
|
||||
"defaultValue" | "onChange"
|
||||
> = {
|
||||
...proxyLanguages,
|
||||
defaultValue: defaultValues.languages,
|
||||
onChange: (event, value) => {
|
||||
event && proxyLanguages.onChange(event)
|
||||
setValue("languages", (value ?? []) as string[])
|
||||
},
|
||||
}
|
||||
|
||||
const controlFIDERating = register("fideRating")
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
closeAfterTransition
|
||||
frame={{
|
||||
as: "form",
|
||||
title: "Filters",
|
||||
footer: (
|
||||
<Button className="float-right py-2" type="submit">
|
||||
Search coaches
|
||||
</Button>
|
||||
),
|
||||
onSubmit: handleSubmit(onSubmit),
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
<Field>
|
||||
<Label htmlFor={`${idPrefix}-languages`}>
|
||||
Preferred Language(s):
|
||||
</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Select languages you prefer communicating in. We{"'"}ll prioritize
|
||||
finding coaches that can speak fluently in at least one of your
|
||||
selections.
|
||||
</p>
|
||||
<SelectLanguage
|
||||
id={`${idPrefix}-languages`}
|
||||
slotProps={{
|
||||
root: { className: "w-full" },
|
||||
}}
|
||||
{...registerLanguages}
|
||||
multiple
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor={`${idPrefix}-fideRating`}>FIDE Rating:</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Find coaches that have a rating within the specified range. Keep in
|
||||
mind, a higher rating does not necessarily mean a better coach{" "}
|
||||
<i>for you</i>. If you are unsure of this or do not have any
|
||||
preference, leave as is.
|
||||
</p>
|
||||
<div id={`${idPrefix}-fideRating`} className="mt-2 w-full px-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={controlFIDERating.name}
|
||||
render={({ field: { onChange, onBlur, value, ref } }) => (
|
||||
<Slider
|
||||
ref={ref}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onChange={(event, newValue: any) => {
|
||||
event && onChange(event)
|
||||
setValue("fideRating.0", newValue[0])
|
||||
setValue("fideRating.1", newValue[1])
|
||||
}}
|
||||
step={10}
|
||||
min={FIDE_RATING_MIN}
|
||||
max={FIDE_RATING_MAX}
|
||||
marks={computeStepLabels(
|
||||
FIDE_RATING_MIN,
|
||||
FIDE_RATING_MAX,
|
||||
7,
|
||||
50
|
||||
).map((s) => ({ value: s, label: `${s}` }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-16 flex flex-wrap items-center justify-center gap-x-20 gap-y-4">
|
||||
<div>
|
||||
<label className="text-neutral-850 text-sm font-medium">
|
||||
Min:
|
||||
</label>
|
||||
<Input value={watch("fideRating.0")} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-neutral-850 text-sm font-medium">
|
||||
Max:
|
||||
</label>
|
||||
<Input value={watch("fideRating.1")} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
import { Border } from "./Border"
|
||||
|
||||
export function GridList({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ul
|
||||
role="list"
|
||||
className={clsx(
|
||||
"grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function GridListItem({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
invert = false,
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
invert?: boolean
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
"text-base",
|
||||
invert
|
||||
? "text-neutral-300 before:bg-white after:bg-white/10"
|
||||
: "text-neutral-600 before:bg-neutral-950 after:bg-neutral-100",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Border position="left" className="pl-8" invert={invert}>
|
||||
<strong
|
||||
className={clsx(
|
||||
"font-semibold",
|
||||
invert ? "text-white" : "text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{title}.
|
||||
</strong>{" "}
|
||||
{children}
|
||||
</Border>
|
||||
</li>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
import { Container } from "./Container"
|
||||
import { FadeIn } from "./FadeIn"
|
||||
|
||||
export function PageIntro({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
centered = false,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string | React.ReactNode
|
||||
children: React.ReactNode
|
||||
centered?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Container
|
||||
className={clsx("mt-24 sm:mt-32 lg:mt-40", centered && "text-center")}
|
||||
>
|
||||
<FadeIn>
|
||||
<h1>
|
||||
<span className="block font-display text-base font-semibold text-neutral-950">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<span className="sr-only"> - </span>
|
||||
<span
|
||||
className={clsx(
|
||||
"mt-6 block max-w-5xl font-display text-5xl font-medium tracking-tight text-neutral-950 [text-wrap:balance] sm:text-6xl",
|
||||
centered && "mx-auto"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</h1>
|
||||
<div
|
||||
className={clsx(
|
||||
"mt-6 max-w-3xl text-xl text-neutral-600",
|
||||
centered && "mx-auto"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FadeIn>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -40,8 +40,6 @@ function Navigation() {
|
|||
<nav className="mt-px font-display text-5xl font-medium tracking-tight text-white">
|
||||
<NavigationRow>
|
||||
<NavigationItem href="/about/">About Us</NavigationItem>
|
||||
</NavigationRow>
|
||||
<NavigationRow>
|
||||
<NavigationItem href="/contact/">Contact Us</NavigationItem>
|
||||
</NavigationRow>
|
||||
</nav>
|
||||
|
|
|
@ -1,41 +1,77 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
type SearchResultProps = {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
src?: string
|
||||
} & React.ComponentPropsWithoutRef<"div">
|
||||
import type { Coach } from "../types/Coach"
|
||||
|
||||
import PawnIcon from "../icons/Pawn"
|
||||
import KnightIcon from "../icons/Knight"
|
||||
|
||||
function getSiteIcon(coach: Coach) {
|
||||
switch (coach.site) {
|
||||
case "chesscom":
|
||||
return ({ className, ...props }: { className?: string }) => (
|
||||
<PawnIcon
|
||||
className={clsx("stroke-black fill-lime-600", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
case "lichess":
|
||||
return ({ className, ...props }: { className?: string }) => (
|
||||
<KnightIcon
|
||||
className={clsx("stroke-black fill-white", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getProfileUrl(coach: Coach) {
|
||||
switch (coach.site) {
|
||||
case "chesscom":
|
||||
return `https://www.chess.com/member/${coach.username}`
|
||||
case "lichess":
|
||||
return `https://lichess.org/coach/${coach.username}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchResult({ coach }: { coach: Coach }) {
|
||||
const profileUrl = getProfileUrl(coach)
|
||||
const Component = profileUrl ? "a" : "div"
|
||||
const Icon = getSiteIcon(coach)
|
||||
|
||||
export function SearchResult({
|
||||
title,
|
||||
subtitle,
|
||||
src,
|
||||
className,
|
||||
...props
|
||||
}: SearchResultProps) {
|
||||
return (
|
||||
<div
|
||||
<Component
|
||||
className={clsx(
|
||||
"group relative h-96 overflow-hidden rounded-3xl bg-neutral-100",
|
||||
className
|
||||
{ "cursor-pointer": profileUrl }
|
||||
)}
|
||||
{...props}
|
||||
href={profileUrl || undefined}
|
||||
target={profileUrl ? "_blank" : undefined}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
src={coach.image_url ?? ""}
|
||||
className="h-full w-full object-cover transition duration-500 motion-safe:group-hover:scale-105"
|
||||
/>
|
||||
{Icon && (
|
||||
<div className="absolute -top-10 -right-10 pt-4 pr-4 flex flex-col justify-end items-start bg-radial-gradient/gray w-[5.2rem] h-[5.2rem]">
|
||||
<Icon className="w-6 h-6 ml-2 mb-2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black to-black/0 to-40% p-6">
|
||||
{title ? (
|
||||
{coach.name ? (
|
||||
<p className="font-display text-base/6 font-semibold tracking-wide text-white">
|
||||
{title}
|
||||
{coach.title ? (
|
||||
<span className="font-bold pr-1">{coach.title}</span>
|
||||
) : null}
|
||||
{coach.name}
|
||||
</p>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<p className="mt-2 text-sm text-white">{subtitle}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm text-white">{coach.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
import { Container } from "./Container"
|
||||
|
||||
export function SectionIntro({
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
smaller = false,
|
||||
invert = false,
|
||||
...props
|
||||
}: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Container>,
|
||||
"title" | "children"
|
||||
> & {
|
||||
title: string
|
||||
eyebrow?: string
|
||||
children?: React.ReactNode
|
||||
smaller?: boolean
|
||||
invert?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Container {...props}>
|
||||
<div className="max-w-2xl">
|
||||
<h2>
|
||||
{eyebrow && (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
"mb-6 block font-display text-base font-semibold",
|
||||
invert ? "text-white" : "text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
</span>
|
||||
<span className="sr-only"> - </span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
"block font-display tracking-tight [text-wrap:balance]",
|
||||
smaller
|
||||
? "text-2xl font-semibold"
|
||||
: "text-4xl font-medium sm:text-5xl",
|
||||
invert ? "text-white" : "text-neutral-950"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</h2>
|
||||
{children && (
|
||||
<div
|
||||
className={clsx(
|
||||
"mt-6 text-xl",
|
||||
invert ? "text-neutral-300" : "text-neutral-600"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from "react"
|
||||
import { SelectProps } from "@mui/base/Select"
|
||||
|
||||
import { Select, Option } from "./Select"
|
||||
import { Title } from "../types/Title"
|
||||
|
||||
interface TitleOption {
|
||||
value: Title | ""
|
||||
label: string
|
||||
}
|
||||
|
||||
const options: TitleOption[] = [
|
||||
{ value: Title.GM, label: "Grandmaster" },
|
||||
{ value: Title.IM, label: "International Master" },
|
||||
{ value: Title.FM, label: "FIDE Master" },
|
||||
{ value: Title.CM, label: "Candidate Master" },
|
||||
{ value: Title.NM, label: "National Master" },
|
||||
{ value: Title.WGM, label: "Woman Grandmaster" },
|
||||
{ value: Title.WIM, label: "Woman International Master" },
|
||||
{ value: Title.WFM, label: "Woman FIDE Master" },
|
||||
{ value: Title.WCM, label: "Woman Candidate Master" },
|
||||
{ value: Title.WNM, label: "Woman National Master" },
|
||||
]
|
||||
|
||||
export type SelectTitleProps = SelectProps<{}, boolean>
|
||||
|
||||
export const SelectTitle = React.forwardRef(function SelectTitle(
|
||||
props: SelectTitleProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>
|
||||
) {
|
||||
return (
|
||||
<Select ref={ref} {...props}>
|
||||
{options.map((entry, index) => {
|
||||
return (
|
||||
<Option key={index} value={entry.value}>
|
||||
{entry.label}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,246 @@
|
|||
import * as React from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
|
||||
import { Button } from "./Button"
|
||||
import { CheckBox } from "./CheckBox"
|
||||
import { Field, FieldSet } from "./FieldSet"
|
||||
import { Input } from "./Input"
|
||||
import { Label } from "./Label"
|
||||
import { Modal } from "./Modal"
|
||||
import { Mode, getModeName } from "../types/Mode"
|
||||
import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
|
||||
import { SelectTitle, SelectTitleProps } from "./SelectTitle"
|
||||
import { Site, getSiteName } from "../types/Site"
|
||||
import { Slider } from "./Slider"
|
||||
import { Title } from "../types/Title"
|
||||
import { RATING_MIN, RATING_MAX, SearchParams } from "../types/SearchParams"
|
||||
|
||||
const computeStepLabels = (
|
||||
min: number,
|
||||
max: number,
|
||||
// The number of labels (+ 1) that should be produced.
|
||||
steps: number,
|
||||
// To which value numbers should be rounded to.
|
||||
round: number
|
||||
) => {
|
||||
let labels = []
|
||||
const delta = Math.floor((max - min) / steps)
|
||||
for (let i = min; i <= max; i += delta) {
|
||||
if (i % round <= round / 2) {
|
||||
labels.push(i - (i % round))
|
||||
} else {
|
||||
labels.push(i + round - (i % round))
|
||||
}
|
||||
}
|
||||
|
||||
labels[labels.length - 1] = max
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
interface SortModalProps {
|
||||
open: boolean
|
||||
defaultValues: SearchParams
|
||||
onClose: () => void
|
||||
onSubmit: (p: SearchParams) => void
|
||||
}
|
||||
|
||||
export function SortModal({
|
||||
open,
|
||||
defaultValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: SortModalProps) {
|
||||
const idPrefix = React.useId()
|
||||
|
||||
const {
|
||||
watch,
|
||||
reset,
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<SearchParams>({
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
// Default values are processed immediately despite the modal not being open
|
||||
// at the start. Furthermore, values are preserved after closing and
|
||||
// re-opening the modal, but we want closing the modal to signify canceling.
|
||||
// A simple workaround is to reset everytime we open the modal.
|
||||
React.useEffect(() => reset(defaultValues), [open])
|
||||
|
||||
// Registration
|
||||
|
||||
const registerSites = register("sites", {
|
||||
required: "Please select at least one site.",
|
||||
})
|
||||
|
||||
const proxyLanguages = register("languages")
|
||||
const registerLanguages: Pick<
|
||||
SelectLanguageProps,
|
||||
"defaultValue" | "onChange"
|
||||
> = {
|
||||
...proxyLanguages,
|
||||
defaultValue: defaultValues.languages,
|
||||
onChange: (event, value) => {
|
||||
event && proxyLanguages.onChange(event)
|
||||
setValue("languages", (value ?? []) as string[])
|
||||
},
|
||||
}
|
||||
|
||||
const registerRating = register("rating")
|
||||
const registerModes = register("modes", {
|
||||
required: "Please select at least one mode.",
|
||||
})
|
||||
|
||||
const proxyTitles = register("titles")
|
||||
const registerTitles: Pick<SelectTitleProps, "defaultValue" | "onChange"> = {
|
||||
...proxyTitles,
|
||||
defaultValue: defaultValues.titles,
|
||||
onChange: (event, value) => {
|
||||
event && proxyTitles.onChange(event)
|
||||
setValue("titles", (value ?? []) as Title[])
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
closeAfterTransition
|
||||
frame={{
|
||||
as: "form",
|
||||
title: "Sort Coaches",
|
||||
footer: (
|
||||
<Button
|
||||
className="float-right py-2"
|
||||
type="submit"
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
),
|
||||
onSubmit: handleSubmit(onSubmit),
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-12">
|
||||
<FieldSet error={errors?.sites?.message}>
|
||||
<Label htmlFor={`${idPrefix}-rating`}>Sites:</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Prioritize coaches from the selected site(s).
|
||||
</p>
|
||||
<div className="grid grid-cols-2 pt-2 text-sm">
|
||||
{(Object.values(Site) as Site[]).map((s) => (
|
||||
<div key={s} className="col-span-1 flex items-center gap-x-2">
|
||||
<CheckBox value={s} {...registerSites} />
|
||||
<div>{getSiteName(s)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor={`${idPrefix}-languages`}>
|
||||
Preferred Language(s):
|
||||
</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Select languages you prefer communicating in. We{"'"}ll prioritize
|
||||
finding coaches that can speak fluently in at least one of your
|
||||
selections.
|
||||
</p>
|
||||
<SelectLanguage
|
||||
id={`${idPrefix}-languages`}
|
||||
slotProps={{
|
||||
root: { className: "w-full" },
|
||||
}}
|
||||
{...registerLanguages}
|
||||
multiple
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor={`${idPrefix}-titles`}>Titles:</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Prioritize coaches with one or more of the following titles. That
|
||||
said, this is usually not an aspect of a coach that is important to
|
||||
focus on.
|
||||
</p>
|
||||
<SelectTitle
|
||||
id={`${idPrefix}-titles`}
|
||||
slotProps={{
|
||||
root: { className: "w-full" },
|
||||
}}
|
||||
{...registerTitles}
|
||||
multiple
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldSet error={errors?.modes?.message}>
|
||||
<Label htmlFor={`${idPrefix}-rating`}>Mode:</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Prefer a specific game mode? We{"'"}ll prioritize coaches that
|
||||
specialize in the modes selected.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 pt-2 text-sm">
|
||||
{(Object.values(Mode) as Mode[]).map((m) => (
|
||||
<div key={m} className="col-span-1 flex items-center gap-x-2">
|
||||
<CheckBox value={m} {...registerModes} />
|
||||
<div>{getModeName(m)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<Field>
|
||||
<Label htmlFor={`${idPrefix}-rating`}>Rating:</Label>
|
||||
<p className="py-2 text-sm">
|
||||
Find coaches that have a rating within the specified range. A higher
|
||||
rating does not necessarily correspond to a better coach. If you are
|
||||
unsure of this or do not have any preference, leave as is.
|
||||
</p>
|
||||
<div id={`${idPrefix}-rating`} className="mt-2 w-full px-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={registerRating.name}
|
||||
render={({ field: { onChange, onBlur, value, ref } }) => (
|
||||
<Slider
|
||||
ref={ref}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onChange={(event, newValue: any) => {
|
||||
event && onChange(event)
|
||||
setValue("rating.0", newValue[0])
|
||||
setValue("rating.1", newValue[1])
|
||||
}}
|
||||
step={10}
|
||||
min={RATING_MIN}
|
||||
max={RATING_MAX}
|
||||
marks={computeStepLabels(RATING_MIN, RATING_MAX, 7, 50).map(
|
||||
(s) => ({ value: s, label: `${s}` })
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-16 flex flex-wrap items-center justify-center gap-x-20 gap-y-4">
|
||||
<div>
|
||||
<label className="text-neutral-850 text-sm font-medium">
|
||||
Min:
|
||||
</label>
|
||||
<Input value={watch("rating.0")} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-neutral-850 text-sm font-medium">
|
||||
Max:
|
||||
</label>
|
||||
<Input value={watch("rating.1")} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -3,28 +3,46 @@ import clsx from "clsx"
|
|||
|
||||
import type { SearchParams } from "../types/SearchParams"
|
||||
|
||||
import FilterIcon from "../icons/Filter"
|
||||
import BulletIcon from "../icons/Bullet"
|
||||
import EnglishIcon from "../icons/English"
|
||||
import SortIcon from "../icons/Sort"
|
||||
import KnightIcon from "../icons/Knight"
|
||||
import LightningIcon from "../icons/Lightning"
|
||||
import PawnIcon from "../icons/Pawn"
|
||||
import RabbitIcon from "../icons/Rabbit"
|
||||
import RightArrowIcon from "../icons/RightArrow"
|
||||
import RisingGraphIcon from "../icons/RisingGraph"
|
||||
import TrophyIcon from "../icons/Trophy"
|
||||
import { Button } from "./Button"
|
||||
import { Mode } from "../types/Mode"
|
||||
import { Site } from "../types/Site"
|
||||
import { Title } from "../types/Title"
|
||||
|
||||
interface FilterOption {
|
||||
interface SortOption {
|
||||
title: string
|
||||
Icon: ({ ...props }: { [x: string]: any }) => React.JSX.Element
|
||||
enable: (p: SearchParams) => SearchParams
|
||||
isEnabled: (p: SearchParams) => boolean
|
||||
}
|
||||
|
||||
const filters: FilterOption[] = [
|
||||
const filters: SortOption[] = [
|
||||
{
|
||||
title: "FIDE 2000+",
|
||||
Icon: RisingGraphIcon,
|
||||
title: "On Lichess",
|
||||
Icon: KnightIcon,
|
||||
enable: (p) => {
|
||||
p.fideRating[0] = Math.max(2000, p.fideRating[0])
|
||||
p.sites.push(Site.LICHESS)
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.fideRating[0] >= 2000,
|
||||
isEnabled: (p) => p.sites.includes(Site.LICHESS),
|
||||
},
|
||||
{
|
||||
title: "On Chess.com",
|
||||
Icon: PawnIcon,
|
||||
enable: (p) => {
|
||||
p.sites.push(Site.CHESSCOM)
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.sites.includes(Site.CHESSCOM),
|
||||
},
|
||||
{
|
||||
title: "English Speaking",
|
||||
|
@ -42,6 +60,51 @@ const filters: FilterOption[] = [
|
|||
isEnabled: (p) =>
|
||||
p.languages.includes("en-US") || p.languages.includes("en-GB"),
|
||||
},
|
||||
{
|
||||
title: "ELO 2000+",
|
||||
Icon: RisingGraphIcon,
|
||||
enable: (p) => {
|
||||
p.rating[0] = Math.max(2000, p.rating[0])
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.rating[0] >= 2000,
|
||||
},
|
||||
{
|
||||
title: "Rapid Specialty",
|
||||
Icon: RabbitIcon,
|
||||
enable: (p) => {
|
||||
p.modes = [Mode.RAPID]
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.RAPID),
|
||||
},
|
||||
{
|
||||
title: "Blitz Specialty",
|
||||
Icon: LightningIcon,
|
||||
enable: (p) => {
|
||||
p.modes = [Mode.BLITZ]
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BLITZ),
|
||||
},
|
||||
{
|
||||
title: "Bullet Specialty",
|
||||
Icon: BulletIcon,
|
||||
enable: (p) => {
|
||||
p.modes = [Mode.BULLET]
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BULLET),
|
||||
},
|
||||
{
|
||||
title: "Titled Player",
|
||||
Icon: TrophyIcon,
|
||||
enable: (p) => {
|
||||
p.titles = Object.keys(Title) as Title[]
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.titles.length > 0,
|
||||
},
|
||||
]
|
||||
|
||||
enum Direction {
|
||||
|
@ -49,13 +112,13 @@ enum Direction {
|
|||
RIGHT,
|
||||
}
|
||||
|
||||
interface FilterScrollProps {
|
||||
interface SortScrollProps {
|
||||
params: SearchParams
|
||||
onModal: () => void
|
||||
onSelect: (p: SearchParams) => void
|
||||
}
|
||||
|
||||
export function FilterScroll({ params, onModal, onSelect }: FilterScrollProps) {
|
||||
export function SortScroll({ params, onModal, onSelect }: SortScrollProps) {
|
||||
const viewport = React.useRef<HTMLDivElement>(null)
|
||||
const [isFlush, setIsFlush] = React.useState([true, false])
|
||||
|
||||
|
@ -75,7 +138,7 @@ export function FilterScroll({ params, onModal, onSelect }: FilterScrollProps) {
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-x-8">
|
||||
<div className="relative flex overflow-hidden">
|
||||
<div className="relative flex flex-grow overflow-hidden">
|
||||
<div
|
||||
ref={viewport}
|
||||
className="flex items-center gap-x-12 overflow-hidden"
|
||||
|
@ -127,8 +190,8 @@ export function FilterScroll({ params, onModal, onSelect }: FilterScrollProps) {
|
|||
</div>
|
||||
</div>
|
||||
<Button className="flex gap-x-2 py-4" onClick={onModal}>
|
||||
<FilterIcon className="h-6 w-6 fill-white" />
|
||||
<span className="font-display">Filter</span>
|
||||
<SortIcon className="h-6 w-6 fill-white" />
|
||||
<span className="font-display">Sort</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
|
@ -0,0 +1,14 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<path d="M297.236 327.676 184.324 214.763 35.455 363.632l-13.606-13.605L0 371.876l13.605 13.605 112.913 112.914h.001L140.124 512l21.849-21.849-13.605-13.606zM479.49 12.536c-3.024 1.166-70.873 27.806-141.442 93.595l67.821 67.821c65.787-70.565 92.429-138.417 93.595-141.441L512 0l-32.51 12.536zM306.914 118.696l-45.368 45.368-55.052 29.17 112.271 112.271 29.17-55.05 45.368-45.369z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -1,14 +0,0 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 32 32"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 9.142V4H6v5.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v10.096h2V16.858c1.72-.447 3-2 3-3.858S9.72 9.589 8 9.142zM7 15a2 2 0 1 1-.001-3.999A2 2 0 0 1 7 15zm10 1.142V4h-2v12.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v3.096h2v-3.096c1.72-.447 3-2 3-3.858s-1.28-3.411-3-3.858zM16 22a2 2 0 1 1-.001-3.999A2 2 0 0 1 16 22zm13-6c0-1.858-1.28-3.411-3-3.858V4h-2v8.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v7.096h2v-7.096c1.72-.447 3-2 3-3.858zm-4 2a2 2 0 1 1-.001-3.999A2 2 0 0 1 25 18z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,14 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 32 32"
|
||||
{...props}
|
||||
>
|
||||
<path d="m7.2 16 1.1-.2c1.6-.3 3.3-.5 5-.7-2.4 2.3-3.9 5.3-4.7 7.9h14.7c.4-1.5 1.1-3 2.3-4.1l.2-.2c.2-.2.3-.4.3-.6.5-5.1-1.9-10.1-6.3-12.8-.8-1.4-2-2.4-3.6-2.9l-.9-.3c-.3-.1-.6-.1-.9.1-.2.2-.4.5-.4.8v2.4l-1.4.7c-.4.2-.6.5-.6.9v.5l-4.7 3.1c-.8.5-1.3 1.5-1.3 2.5V15c0 .3.1.6.4.8.2.2.5.2.8.2zM6.8 25c-.5.5-.8 1.2-.8 2v2c0 .6.4 1 1 1h18c.6 0 1-.4 1-1v-2c0-.8-.3-1.5-.8-2H6.8z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,13 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" {...props}>
|
||||
<path
|
||||
d="M30.8 2.29a.49.49 0 0 0-.45-.29H16.42a.5.5 0 0 0-.42.23l-10.71 17a.49.49 0 0 0 .41.77h7.67L6.6 33.25a.52.52 0 0 0 .46.75h3a.5.5 0 0 0 .37-.16L28 14.85a.5.5 0 0 0-.37-.85h-6.74l9.83-11.18a.49.49 0 0 0 .08-.53Z"
|
||||
className="clr-i-solid clr-i-solid-path-1"
|
||||
/>
|
||||
<path fill="none" d="M0 0h36v36H0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,9 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" {...props}>
|
||||
<path d="M22.777 23.384c-.147-.983-.91-1.857-2.058-2.507-3.059-1.95-3.595-5.268-2.184-7.45 1.799-.518 3.028-1.562 3.028-2.766 0-1.095-1.017-2.058-2.555-2.613a4.336 4.336 0 0 0 1.277-3.079c0-2.396-1.933-4.338-4.318-4.338s-4.318 1.942-4.318 4.338c0 1.204.488 2.292 1.276 3.078-1.538.555-2.556 1.518-2.556 2.613 0 1.218 1.259 2.273 3.093 2.784 1.434 2.175.824 5.451-2.332 7.463-1.107.646-1.834 1.513-1.975 2.477-1.989.842-3.235 2.047-3.235 3.386 0 2.544 4.498 4.607 10.047 4.607s10.047-2.062 10.047-4.607c0-1.339-1.247-2.545-3.237-3.387z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,9 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" {...props}>
|
||||
<path d="M23.961 5.99a6.144 6.144 0 0 0-1.03.065c-4.414-1.996-5.841-4.914-7.812-4.914-1.705 0-1.62 3.149 4.884 6.521l-.035.046c-.496.68-6.986-3.097-8.985-3.093-2.692.006 1.257 6.368 7.689 5.369.61-.095-.302.909-.227 1.48.023.176.065.345.123.506-6.275-.164-10.188.982-12.463 2.774-1.616-.903-1.672-4.089-3.337-3.265-1.77.876-.679 5.582.831 7.142-1.022 4.432 2.247 9.722 4.846 11.331h20.509c1.112-.789.487-1.41 0-1.997-.602-.725-2.461-1.199-3.993-.998-2.23-.908 5.444-5.973.027-11.95 1.021.058 2.186-.023 3.2-.342l.071.049.045-.086c.931-.313 1.723-.836 2.137-1.648.51-.998-3.303-6.922-6.479-6.989zm.996 4.983a.998.998 0 1 1 0-1.996.998.998 0 0 1 0 1.996z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,14 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponent = ({ ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path d="M6.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L8 7.414V19a1 1 0 1 1-2 0V7.414L3.707 9.707a1 1 0 0 1-1.414-1.414l4-4zM16 16.586V5a1 1 0 1 1 2 0v11.586l2.293-2.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L16 16.586z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponent
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from "react"
|
||||
|
||||
const SvgComponet = ({ ...props }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
width={800}
|
||||
height={800}
|
||||
viewBox="0 0 100 100"
|
||||
{...props}
|
||||
>
|
||||
<path d="M82.296 23.931a1.918 1.918 0 0 0-1.918-1.918h-8.074v-6.998c.004-.061.018-.118.018-.179a2.935 2.935 0 0 0-2.934-2.935c-.036 0-.07.009-.106.011v-.011H30.365v.054a2.925 2.925 0 0 0-2.696 2.911c0 .062.014.119.018.179v6.967H19.62a1.914 1.914 0 0 0-1.909 1.839h-.007v.073l-.001.007.001.007v26.038l-.001.004.001.009V50h.001c.01 1.051.863 1.9 1.916 1.9h8.328c1.354 9.109 8.197 16.422 17.069 18.449v12.746h-9.969a2.493 2.493 0 0 0 0 4.988v.017h29.894v-.017a2.493 2.493 0 0 0 0-4.988h-9.969V70.353c8.881-2.02 15.733-9.337 17.087-18.453h8.318c1.028 0 1.86-.81 1.909-1.825h.011V23.931h-.003zM27.687 46.913H22.69V27h4.997v19.913zm49.623 0h-5.007V27h5.007v19.913z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default SvgComponet
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { PageIntro } from "../components/PageIntro"
|
||||
|
||||
const Title = (
|
||||
<span>
|
||||
The{" "}
|
||||
<span className="bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500 bg-clip-text text-transparent">
|
||||
BoardWise
|
||||
</span>{" "}
|
||||
Mission
|
||||
</span>
|
||||
)
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<>
|
||||
<PageIntro eyebrow="About Us" title={Title}>
|
||||
<p>A better approach to finding the right coach for you.</p>
|
||||
<p className="mt-4 text-base">
|
||||
We are a small group of chess enthusiasts dedicated to helping other
|
||||
players improve their game as efficiently as they can. We{"'"}re
|
||||
starting this initiative the best way we know how - with experts in
|
||||
the field.
|
||||
</p>
|
||||
</PageIntro>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { PageIntro } from "../components/PageIntro"
|
||||
|
||||
export function Contact() {
|
||||
return (
|
||||
<>
|
||||
<PageIntro eyebrow="Contact Us" title="Questions or Comments?">
|
||||
<p>
|
||||
Tell us how we can improve our site by leaving an issue at{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline"
|
||||
href="https://github.com/boardwise-gg/website"
|
||||
>
|
||||
our GitHub repo
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</PageIntro>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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 { SortModal } from "../components/SortModal"
|
||||
import { SortScroll } from "../components/SortScroll"
|
||||
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,20 +50,26 @@ 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 ?? ""}
|
||||
/>
|
||||
</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 flex-col"
|
||||
>
|
||||
<SearchResult coach={coach} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage ? <Loading className="pt-20" loading /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -51,12 +79,12 @@ export function Search() {
|
|||
|
||||
return (
|
||||
<Container className="pt-8">
|
||||
<FilterScroll
|
||||
<SortScroll
|
||||
params={searchParams}
|
||||
onSelect={setSearchParams}
|
||||
onModal={() => setModalOpen(true)}
|
||||
/>
|
||||
<FilterModal
|
||||
<SortModal
|
||||
open={modalOpen}
|
||||
defaultValues={searchParams}
|
||||
onClose={() => setModalOpen(false)}
|
||||
|
@ -65,7 +93,7 @@ export function Search() {
|
|||
setModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
<SearchResults />
|
||||
<SearchResults searchParams={searchParams} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import * as React from "react"
|
|||
import { createBrowserRouter } from "react-router-dom"
|
||||
|
||||
import { FallbackMessage } from "./components/FallbackMessage"
|
||||
import { About } from "./pages/About"
|
||||
import { Contact } from "./pages/Contact"
|
||||
import { Search } from "./pages/Search"
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
|
@ -9,6 +11,14 @@ export const router = createBrowserRouter([
|
|||
path: "/",
|
||||
element: <Search />,
|
||||
},
|
||||
{
|
||||
path: "/about/",
|
||||
element: <About />,
|
||||
},
|
||||
{
|
||||
path: "/contact/",
|
||||
element: <Contact />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: (
|
||||
|
|
|
@ -3,6 +3,7 @@ export type Coach = {
|
|||
username: string
|
||||
name: string | null
|
||||
image_url: string | null
|
||||
title: string | null
|
||||
languages: string[] | null
|
||||
rapid: number | null
|
||||
blitz: number | null
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export enum Mode {
|
||||
RAPID = "RAPID",
|
||||
BLITZ = "BLITZ",
|
||||
BULLET = "BULLET",
|
||||
}
|
||||
|
||||
export const getModeName = (mode: Mode) => {
|
||||
return mode.charAt(0) + mode.toLowerCase().slice(1)
|
||||
}
|
|
@ -1,12 +1,45 @@
|
|||
import { Mode } from "./Mode"
|
||||
import { Site } from "./Site"
|
||||
import { Title } from "./Title"
|
||||
|
||||
export type SearchParams = {
|
||||
fideRating: [number, number]
|
||||
rating: [number, number]
|
||||
modes: Mode[]
|
||||
languages: string[]
|
||||
titles: Title[]
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export const FIDE_RATING_MIN = 1500
|
||||
export const FIDE_RATING_MAX = 3200
|
||||
export const RATING_MIN = 1500
|
||||
export const RATING_MAX = 3200
|
||||
|
||||
export const defaultSearchParams: SearchParams = {
|
||||
fideRating: [FIDE_RATING_MIN, FIDE_RATING_MAX],
|
||||
rating: [RATING_MIN, RATING_MAX],
|
||||
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
|
||||
languages: [],
|
||||
titles: [],
|
||||
sites: [Site.LICHESS],
|
||||
}
|
||||
|
||||
export function toQueryParams(p: SearchParams) {
|
||||
const queryParams: { [key: string]: any } = {}
|
||||
|
||||
if (p.sites.length > 0) {
|
||||
queryParams["sites"] = p.sites.join(",")
|
||||
}
|
||||
|
||||
for (const mode of p.modes) {
|
||||
queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0]
|
||||
queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1]
|
||||
}
|
||||
|
||||
if (p.languages.length > 0) {
|
||||
queryParams["languages"] = p.languages.join(",")
|
||||
}
|
||||
|
||||
if (p.titles) {
|
||||
queryParams["titles"] = p.titles.join(",")
|
||||
}
|
||||
|
||||
return queryParams
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
export enum Site {
|
||||
CHESSCOM = "chesscom",
|
||||
LICHESS = "lichess",
|
||||
}
|
||||
|
||||
export function getSiteName(site: Site) {
|
||||
switch (site) {
|
||||
case Site.CHESSCOM:
|
||||
return "Chess.com"
|
||||
case Site.LICHESS:
|
||||
return "Lichess"
|
||||
default:
|
||||
const _exhaustivenessCheck: never = site
|
||||
return _exhaustivenessCheck
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export enum Title {
|
||||
GM = "GM",
|
||||
IM = "IM",
|
||||
FM = "FM",
|
||||
CM = "CM",
|
||||
NM = "NM",
|
||||
WGM = "WGM",
|
||||
WIM = "WIM",
|
||||
WFM = "WFM",
|
||||
WCM = "WCM",
|
||||
WNM = "WNM",
|
||||
}
|
|
@ -1,22 +1,38 @@
|
|||
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, toQueryParams } 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) => {
|
||||
const queryParams = toQueryParams(searchParams)
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["coaches", queryParams],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
|
||||
params: {
|
||||
page_no: pageParam,
|
||||
page_size: 15,
|
||||
...queryParams,
|
||||
},
|
||||
})
|
||||
return response.data.data
|
||||
},
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||
if (lastPage.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return lastPageParam + 1
|
||||
},
|
||||
initialPageParam: 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
|
||||
|
|
|
@ -28,8 +28,8 @@ module.exports = {
|
|||
},
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"radial-gradient/black":
|
||||
"radial-gradient(circle at center, black 0%, transparent 50%)",
|
||||
"radial-gradient/gray":
|
||||
"radial-gradient(circle at center, #d3d3d3 0%, transparent 100%)",
|
||||
},
|
||||
borderRadius: {
|
||||
"4xl": "2.5rem",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// https://esbuild.github.io/content-types/#tsconfig-json
|
||||
"compilerOptions": {
|
||||
// Keep in mind that ES6+ syntax to ES5 is not supported in esbuild yet.
|
||||
"target": "es2016",
|
||||
"target": "es2017",
|
||||
// https://www.typescriptlang.org/docs/handbook/modules/theory.html
|
||||
"module": "nodenext",
|
||||
// Even when transpiling a single module, the TypeScript compiler actually
|
||||
|
|
12
flake.lock
12
flake.lock
|
@ -19,11 +19,11 @@
|
|||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -34,11 +34,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1700794826,
|
||||
"narHash": "sha256-RyJTnTNKhO0yqRpDISk03I/4A67/dp96YRxc86YOPgU=",
|
||||
"lastModified": 1701718080,
|
||||
"narHash": "sha256-6ovz0pG76dE0P170pmmZex1wWcQoeiomUZGggfH9XPs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5a09cb4b393d58f9ed0d9ca1555016a8543c2ac8",
|
||||
"rev": "2c7f3c0fb7c08a0814627611d9d7d45ab6d75335",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -7,21 +7,88 @@ defmodule BoardWise.Coaches do
|
|||
alias BoardWise.Repo
|
||||
|
||||
alias BoardWise.Coaches.Coach
|
||||
alias BoardWise.Coaches.QueryParams
|
||||
|
||||
@prefix "coach_scraper"
|
||||
|
||||
defmacrop rating_fragment(field, gte, lte) do
|
||||
quote do
|
||||
fragment(
|
||||
"""
|
||||
CASE
|
||||
WHEN ? IS NULL THEN 0
|
||||
WHEN ? IS NULL THEN 0
|
||||
WHEN ? >= ? AND ? <= ? THEN 5
|
||||
ELSE 0
|
||||
END
|
||||
""",
|
||||
type(unquote(gte), :integer),
|
||||
type(unquote(lte), :integer),
|
||||
unquote(field),
|
||||
type(unquote(gte), :integer),
|
||||
unquote(field),
|
||||
type(unquote(lte), :integer)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of coaches.
|
||||
Return the list of coaches according to the specified params.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_coaches()
|
||||
iex> list_coaches(%QueryParams{...})
|
||||
[%Coach{}, ...]
|
||||
|
||||
"""
|
||||
def list_coaches do
|
||||
def list_coaches(%QueryParams{
|
||||
:rapid_gte => rapid_gte,
|
||||
:rapid_lte => rapid_lte,
|
||||
:blitz_gte => blitz_gte,
|
||||
:blitz_lte => blitz_lte,
|
||||
:bullet_gte => bullet_gte,
|
||||
:bullet_lte => bullet_lte,
|
||||
:languages => languages,
|
||||
:titles => titles,
|
||||
:sites => sites,
|
||||
:page_no => page_no,
|
||||
:page_size => page_size
|
||||
}) do
|
||||
Coach
|
||||
|> limit(6)
|
||||
|> select([c], c)
|
||||
|> select_merge(
|
||||
[c],
|
||||
%{
|
||||
score:
|
||||
fragment(
|
||||
"""
|
||||
CASE WHEN ? IS NOT NULL THEN 1000 ELSE 0 END +
|
||||
CASE WHEN ? IS NOT NULL THEN 500 ELSE 0 END +
|
||||
? +
|
||||
? +
|
||||
? +
|
||||
(5 * (SELECT COUNT(*) FROM UNNEST(?) WHERE UNNEST = ANY(?))) +
|
||||
CASE WHEN ? = ANY(?) THEN 5 ELSE 0 END +
|
||||
CASE WHEN ? = ANY(?) THEN 30 ELSE 0 END
|
||||
""",
|
||||
c.name,
|
||||
c.image_url,
|
||||
rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte),
|
||||
rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte),
|
||||
rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte),
|
||||
c.languages,
|
||||
type(^languages, {:array, :string}),
|
||||
c.title,
|
||||
type(^titles, {:array, :string}),
|
||||
c.site,
|
||||
type(^sites, {:array, :string})
|
||||
)
|
||||
|> selected_as(:score)
|
||||
}
|
||||
)
|
||||
|> order_by(desc: selected_as(:score), asc: :position, asc: :username, asc: :id)
|
||||
|> limit(^page_size)
|
||||
|> offset(^((page_no - 1) * page_size))
|
||||
|> Repo.all(prefix: @prefix)
|
||||
end
|
||||
|
||||
|
|
|
@ -15,14 +15,22 @@ defmodule BoardWise.Coaches.Coach do
|
|||
import Ecto.Changeset
|
||||
|
||||
schema "export" do
|
||||
# required fields
|
||||
field :site, :string
|
||||
field :username, :string
|
||||
|
||||
# optional fields
|
||||
field :name, :string
|
||||
field :image_url, :string
|
||||
field :title, :string
|
||||
field :languages, {:array, :string}
|
||||
field :rapid, :integer
|
||||
field :blitz, :integer
|
||||
field :bullet, :integer
|
||||
field :rapid, :integer
|
||||
field :position, :integer
|
||||
|
||||
# virtual fields
|
||||
field :score, :integer, virtual: true
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
@ -33,9 +41,12 @@ defmodule BoardWise.Coaches.Coach do
|
|||
:username,
|
||||
:name,
|
||||
:image_url,
|
||||
:title,
|
||||
:languages,
|
||||
:rapid,
|
||||
:blitz,
|
||||
:bullet
|
||||
:bullet,
|
||||
:position
|
||||
])
|
||||
|> validate_required([:site, :username])
|
||||
|> unique_constraint(:site_username_unique, name: :site_username_unique)
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
defmodule BoardWise.Coaches.QueryParams do
|
||||
defstruct [
|
||||
:rapid_gte,
|
||||
:rapid_lte,
|
||||
:blitz_gte,
|
||||
:blitz_lte,
|
||||
:bullet_gte,
|
||||
:bullet_lte,
|
||||
:titles,
|
||||
:languages,
|
||||
:sites,
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
]
|
||||
end
|
|
@ -5,13 +5,14 @@ defmodule BoardWise.Languages.Language do
|
|||
schema "languages" do
|
||||
field :code, :string
|
||||
field :name, :string
|
||||
field :pos, :integer
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(language, attrs) do
|
||||
language
|
||||
|> cast(attrs, [:code, :name])
|
||||
|> validate_required([:code, :name])
|
||||
|> cast(attrs, [:code, :name, :pos])
|
||||
|> validate_required([:code, :name, :pos])
|
||||
|> unique_constraint(:code_unique, name: :code_unique)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<link rel="icon" href={~p"/favicon.ico"} type="image/x-icon" />
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<title>BoardWise</title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer type="text/javascript" src={~p"/assets/react/main.js"}>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<link rel="icon" href={~p"/favicon.ico"} type="image/x-icon" />
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<.live_title suffix="">
|
||||
<%= assigns[:page_title] || "BoardWise" %>
|
||||
</.live_title>
|
||||
|
|
|
@ -2,9 +2,43 @@ defmodule BoardWiseWeb.CoachController do
|
|||
use BoardWiseWeb, :controller
|
||||
|
||||
alias BoardWise.Coaches
|
||||
alias BoardWise.Coaches.QueryParams
|
||||
|
||||
def index(conn, _params) do
|
||||
coaches = Coaches.list_coaches()
|
||||
def index(conn, params) do
|
||||
query_params =
|
||||
%QueryParams{}
|
||||
|> override_param(:rapid_gte, params, :integer)
|
||||
|> override_param(:rapid_lte, params, :integer)
|
||||
|> override_param(:blitz_gte, params, :integer)
|
||||
|> override_param(:blitz_lte, params, :integer)
|
||||
|> override_param(:bullet_gte, params, :integer)
|
||||
|> override_param(:bullet_lte, params, :integer)
|
||||
|> override_param(:languages, params, :strlist)
|
||||
|> override_param(:titles, params, :strlist)
|
||||
|> override_param(:sites, params, :strlist)
|
||||
|> override_param(:page_no, params, :integer)
|
||||
|> override_param(:page_size, params, :integer)
|
||||
|
||||
# Ensure we never attempt to query too large of a response all at once.
|
||||
query_params = %{query_params | page_size: Enum.min([query_params.page_size, 25])}
|
||||
|
||||
coaches = Coaches.list_coaches(query_params)
|
||||
render(conn, :index, coaches: coaches)
|
||||
end
|
||||
|
||||
defp override_param(query_params, key, params, type) do
|
||||
case Map.get(params, Atom.to_string(key)) do
|
||||
nil ->
|
||||
query_params
|
||||
|
||||
val when type == :strlist ->
|
||||
%{query_params | key => String.split(val, ",")}
|
||||
|
||||
val when type == :integer ->
|
||||
case Integer.parse(val) do
|
||||
{parsed, ""} -> %{query_params | key => parsed}
|
||||
_ -> query_params
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,6 +14,7 @@ defmodule BoardWiseWeb.CoachJSON do
|
|||
username: coach.username,
|
||||
name: coach.name,
|
||||
image_url: coach.image_url,
|
||||
title: coach.title,
|
||||
languages: coach.languages,
|
||||
rapid: coach.rapid,
|
||||
blitz: coach.blitz,
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
defmodule BoardWise.Repo.Migrations.Titles do
|
||||
use Ecto.Migration
|
||||
|
||||
@prefix "coach_scraper"
|
||||
|
||||
def change do
|
||||
alter table(:export, prefix: @prefix) do
|
||||
add :title, :string
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,11 @@
|
|||
defmodule BoardWise.Repo.Migrations.Position do
|
||||
use Ecto.Migration
|
||||
|
||||
@prefix "coach_scraper"
|
||||
|
||||
def change do
|
||||
alter table(:export, prefix: @prefix) do
|
||||
add :position, :integer
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,14 +5,15 @@ defmodule BoardWise.CoachesTest do
|
|||
|
||||
describe "coaches" do
|
||||
alias BoardWise.Coaches.Coach
|
||||
alias BoardWise.Coaches.QueryParams
|
||||
|
||||
import BoardWise.CoachesFixtures
|
||||
|
||||
@invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil}
|
||||
|
||||
test "list_coaches/0 returns all coaches" do
|
||||
test "list_coaches/2 returns all coaches" do
|
||||
coach = coach_fixture()
|
||||
assert Coaches.list_coaches() == [coach]
|
||||
assert Coaches.list_coaches(%QueryParams{}) == [%{coach | score: 0}]
|
||||
end
|
||||
|
||||
test "get_coach!/1 returns the coach with given id" do
|
||||
|
|
|
@ -21,7 +21,7 @@ defmodule BoardWise.LanguagesTest do
|
|||
end
|
||||
|
||||
test "create_language/1 with valid data creates a language" do
|
||||
valid_attrs = %{code: "some code", name: "some name"}
|
||||
valid_attrs = %{code: "some code", name: "some name", pos: 1000}
|
||||
|
||||
assert {:ok, %Language{} = language} = Languages.create_language(valid_attrs)
|
||||
assert language.code == "some code"
|
||||
|
|
|
@ -12,7 +12,8 @@ defmodule BoardWise.LanguagesFixtures do
|
|||
attrs
|
||||
|> Enum.into(%{
|
||||
code: "some code",
|
||||
name: "some name"
|
||||
name: "some name",
|
||||
pos: 0
|
||||
})
|
||||
|> BoardWise.Languages.create_language()
|
||||
|
||||
|
|
Loading…
Reference in New Issue