Compare commits

..

24 Commits

Author SHA1 Message Date
Joshua Potter 0d5a66c604 Drop Plausible. 2024-01-18 19:24:59 -07:00
Joshua Potter ef264d6670 Add Plausible to React root as well. 2024-01-10 12:16:13 -07:00
Joshua Potter 4d13cf9056 Include plausible non-invasive analytics. 2024-01-10 12:09:28 -07:00
Joshua Potter db73e3b4f0 Simplify favicon href. 2023-12-12 12:22:53 -07:00
Joshua Potter 0c7d2b5932 Have contact refer to github repo. 2023-12-12 08:30:20 -07:00
Joshua Potter c605a09c56 Update flake.lock. 2023-12-08 12:36:54 -07:00
Joshua Potter 0eca8e5f5f Add first pass of about and contact pages. 2023-12-07 08:35:45 -07:00
Joshua Potter 2e7efa5c49 Add a random order for initial page load. 2023-12-07 08:12:32 -07:00
Joshua Potter 8d7a2e4853 Rename filter to sort. 2023-12-07 07:58:40 -07:00
Joshua Potter c54f832935 Add filters on source site. 2023-12-07 07:44:59 -07:00
Joshua Potter 75e915e71c Incorporate titles into the search. 2023-12-07 05:45:40 -07:00
Joshua Potter eb812d8366 Include coach titles. 2023-12-06 19:59:29 -07:00
Joshua Potter 8a3320e279 Allow searching languages. Prefer profiles with images/names. 2023-12-06 16:48:53 -07:00
Joshua Potter 286b3dd31d Apply initial ordering to search results. 2023-12-06 16:24:40 -07:00
Joshua Potter e927eab560 Reduce navigation items to a single row. 2023-12-06 09:21:57 -07:00
Joshua Potter a75abbe67d Add game mode specialties. 2023-12-06 09:16:57 -07:00
Joshua Potter c6538c884b Hide filter scroll buttons for now. 2023-12-06 09:06:22 -07:00
Joshua Potter 283bf59546 Migrate game modes. 2023-12-06 08:50:57 -07:00
Joshua Potter 2660cac8a8 Allow infinite scrolling of coaches. 2023-12-06 07:47:53 -07:00
Joshua Potter 2a4d969030 Link coaches to their chesscom/lichess page. 2023-12-05 16:14:35 -07:00
Joshua Potter 54c7d14669 Fix `languages` tests. 2023-12-05 16:09:19 -07:00
Joshua Potter e43009f166 Maintain order on languages. 2023-12-05 16:07:42 -07:00
Joshua Potter 5691052a97 Ensure language defaultValue is used. 2023-12-05 16:07:32 -07:00
Joshua Potter a83d54f6a2
Migrate language selection. (#3)
* Setup to load in languages.

* Retrieve languages from JSON endpoint.

* Allow querying and filtering on language.
2023-12-05 15:32:30 -07:00
48 changed files with 1187 additions and 293 deletions

View File

@ -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}
/>
)
}

View File

@ -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}
/>
)
}
)

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 -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>
)
}
})

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

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

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

@ -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>
)
})

View File

@ -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>
)
}

View File

@ -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>
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>
</>
)
}

View File

@ -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>
</>
)
}

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 { 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>
)
}

View File

@ -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: (

View File

@ -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

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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",
}

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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": {

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"}>

View File

@ -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>

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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()