Add filters on source site.

main
Joshua Potter 2023-12-07 07:44:59 -07:00
parent 75e915e71c
commit c54f832935
14 changed files with 178 additions and 61 deletions

View File

@ -10,6 +10,7 @@ import { Modal } from "./Modal"
import { Mode, getModeName } from "../types/Mode" import { Mode, getModeName } from "../types/Mode"
import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage" import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
import { SelectTitle, SelectTitleProps } from "./SelectTitle" import { SelectTitle, SelectTitleProps } from "./SelectTitle"
import { Site, getSiteName } from "../types/Site"
import { Slider } from "./Slider" import { Slider } from "./Slider"
import { Title } from "../types/Title" import { Title } from "../types/Title"
import { import {
@ -77,6 +78,10 @@ export function FilterModal({
// Registration // Registration
const registerSites = register("sites", {
required: "Please select at least one site.",
})
const proxyLanguages = register("languages") const proxyLanguages = register("languages")
const registerLanguages: Pick< const registerLanguages: Pick<
SelectLanguageProps, SelectLanguageProps,
@ -126,6 +131,21 @@ export function FilterModal({
}} }}
> >
<div className="flex flex-col gap-12"> <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> <Field>
<Label htmlFor={`${idPrefix}-languages`}> <Label htmlFor={`${idPrefix}-languages`}>
Preferred Language(s): Preferred Language(s):
@ -162,6 +182,22 @@ export function FilterModal({
/> />
</Field> </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> <Field>
<Label htmlFor={`${idPrefix}-rating`}>Rating:</Label> <Label htmlFor={`${idPrefix}-rating`}>Rating:</Label>
<p className="py-2 text-sm"> <p className="py-2 text-sm">
@ -208,22 +244,6 @@ export function FilterModal({
</div> </div>
</div> </div>
</Field> </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.keys(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>
</div> </div>
</Modal> </Modal>
) )

View File

@ -6,13 +6,16 @@ import type { SearchParams } from "../types/SearchParams"
import BulletIcon from "../icons/Bullet" import BulletIcon from "../icons/Bullet"
import EnglishIcon from "../icons/English" import EnglishIcon from "../icons/English"
import FilterIcon from "../icons/Filter" import FilterIcon from "../icons/Filter"
import KnightIcon from "../icons/Knight"
import LightningIcon from "../icons/Lightning" import LightningIcon from "../icons/Lightning"
import PawnIcon from "../icons/Pawn"
import RabbitIcon from "../icons/Rabbit" import RabbitIcon from "../icons/Rabbit"
import RightArrowIcon from "../icons/RightArrow" import RightArrowIcon from "../icons/RightArrow"
import RisingGraphIcon from "../icons/RisingGraph" import RisingGraphIcon from "../icons/RisingGraph"
import TrophyIcon from "../icons/Trophy" import TrophyIcon from "../icons/Trophy"
import { Button } from "./Button" import { Button } from "./Button"
import { Mode } from "../types/Mode" import { Mode } from "../types/Mode"
import { Site } from "../types/Site"
import { Title } from "../types/Title" import { Title } from "../types/Title"
interface FilterOption { interface FilterOption {
@ -75,11 +78,29 @@ const filters: FilterOption[] = [
}, },
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BULLET), isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BULLET),
}, },
{
title: "On Chess.com",
Icon: PawnIcon,
enable: (p) => {
p.sites.push(Site.CHESSCOM)
return p
},
isEnabled: (p) => p.sites.includes(Site.CHESSCOM),
},
{
title: "On Lichess",
Icon: KnightIcon,
enable: (p) => {
p.sites.push(Site.LICHESS)
return p
},
isEnabled: (p) => p.sites.includes(Site.LICHESS),
},
{ {
title: "Titled Player", title: "Titled Player",
Icon: TrophyIcon, Icon: TrophyIcon,
enable: (p) => { enable: (p) => {
p.titles = Object.keys(Title) p.titles = Object.keys(Title) as Title[]
return p return p
}, },
isEnabled: (p) => p.titles.length > 0, isEnabled: (p) => p.titles.length > 0,

View File

@ -1,41 +1,77 @@
import * as React from "react" import * as React from "react"
import clsx from "clsx" import clsx from "clsx"
type SearchResultProps = { import type { Coach } from "../types/Coach"
title?: string
subtitle?: string import PawnIcon from "../icons/Pawn"
src?: string import KnightIcon from "../icons/Knight"
} & React.ComponentPropsWithoutRef<"a">
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 ( return (
<a <Component
className={clsx( className={clsx(
"group relative h-96 overflow-hidden rounded-3xl bg-neutral-100", "group relative h-96 overflow-hidden rounded-3xl bg-neutral-100",
className { "cursor-pointer": profileUrl }
)} )}
{...props} href={profileUrl || undefined}
target={profileUrl ? "_blank" : undefined}
> >
<img <img
src={src} src={coach.image_url ?? ""}
className="h-full w-full object-cover transition duration-500 motion-safe:group-hover:scale-105" 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"> <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"> <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> </p>
) : null} ) : null}
{subtitle ? ( <p className="mt-2 text-sm text-white">{coach.username}</p>
<p className="mt-2 text-sm text-white">{subtitle}</p>
) : null}
</div> </div>
</a> </Component>
) )
} }

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

@ -60,21 +60,9 @@ function SearchResults({ searchParams }: { searchParams: SearchParams }) {
{group.map((coach) => ( {group.map((coach) => (
<FadeIn <FadeIn
key={`${coach.site}-${coach.username}`} key={`${coach.site}-${coach.username}`}
className="flex cursor-pointer flex-col" className="flex flex-col"
> >
<SearchResult <SearchResult coach={coach} />
src={coach.image_url ?? ""}
title={coach.name ?? ""}
subtitle={coach.title ?? ""}
target="_blank"
href={
coach.site === "lichess"
? `https://lichess.org/coach/${coach.username}`
: coach.site === "chesscom"
? `https://www.chess.com/member/${coach.username}`
: undefined
}
/>
</FadeIn> </FadeIn>
))} ))}
</React.Fragment> </React.Fragment>

View File

@ -4,6 +4,6 @@ export enum Mode {
BULLET = "BULLET", BULLET = "BULLET",
} }
export const getModeName = (m: Mode) => { export const getModeName = (mode: Mode) => {
return m.charAt(0) + m.toLowerCase().slice(1) return mode.charAt(0) + mode.toLowerCase().slice(1)
} }

View File

@ -1,4 +1,5 @@
import { Mode } from "./Mode" import { Mode } from "./Mode"
import { Site } from "./Site"
import { Title } from "./Title" import { Title } from "./Title"
export type SearchParams = { export type SearchParams = {
@ -6,6 +7,7 @@ export type SearchParams = {
modes: Mode[] modes: Mode[]
languages: string[] languages: string[]
titles: Title[] titles: Title[]
sites: Site[]
} }
export const FIDE_RATING_MIN = 1500 export const FIDE_RATING_MIN = 1500
@ -16,11 +18,16 @@ export const defaultSearchParams: SearchParams = {
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET], modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
languages: [], languages: [],
titles: [], titles: [],
sites: [Site.CHESSCOM, Site.LICHESS],
} }
export function toQueryParams(p: SearchParams) { export function toQueryParams(p: SearchParams) {
const queryParams: { [key: string]: any } = {} const queryParams: { [key: string]: any } = {}
if (p.sites.length > 0) {
queryParams["sites"] = p.sites.join(",")
}
for (const mode of p.modes) { for (const mode of p.modes) {
queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0] queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0]
queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1] queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1]

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

@ -28,8 +28,8 @@ module.exports = {
}, },
extend: { extend: {
backgroundImage: { backgroundImage: {
"radial-gradient/black": "radial-gradient/gray":
"radial-gradient(circle at center, black 0%, transparent 50%)", "radial-gradient(circle at center, #d3d3d3 0%, transparent 100%)",
}, },
borderRadius: { borderRadius: {
"4xl": "2.5rem", "4xl": "2.5rem",

View File

@ -2,7 +2,7 @@
// https://esbuild.github.io/content-types/#tsconfig-json // https://esbuild.github.io/content-types/#tsconfig-json
"compilerOptions": { "compilerOptions": {
// Keep in mind that ES6+ syntax to ES5 is not supported in esbuild yet. // 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 // https://www.typescriptlang.org/docs/handbook/modules/theory.html
"module": "nodenext", "module": "nodenext",
// Even when transpiling a single module, the TypeScript compiler actually // Even when transpiling a single module, the TypeScript compiler actually

View File

@ -50,6 +50,7 @@ defmodule BoardWise.Coaches do
:bullet_lte => bullet_lte, :bullet_lte => bullet_lte,
:languages => languages, :languages => languages,
:titles => titles, :titles => titles,
:sites => sites,
:page_no => page_no, :page_no => page_no,
:page_size => page_size :page_size => page_size
}) do }) do
@ -67,17 +68,20 @@ defmodule BoardWise.Coaches do
? + ? +
? + ? +
(5 * (SELECT COUNT(*) FROM UNNEST(?) WHERE UNNEST = ANY(?))) + (5 * (SELECT COUNT(*) FROM UNNEST(?) WHERE UNNEST = ANY(?))) +
CASE WHEN ? = ANY(?) THEN 5 ELSE 0 END CASE WHEN ? = ANY(?) THEN 5 ELSE 0 END +
CASE WHEN ? = ANY(?) THEN 30 ELSE 0 END
""", """,
c.name, c.name,
c.image_url, c.image_url,
rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte), rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte),
rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte), rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte),
rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte), rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte),
type(^languages, {:array, :string}),
c.languages, c.languages,
type(^languages, {:array, :string}),
c.title, c.title,
type(^titles, {:array, :string}) type(^titles, {:array, :string}),
c.site,
type(^sites, {:array, :string})
) )
|> selected_as(:score) |> selected_as(:score)
} }

View File

@ -8,6 +8,7 @@ defmodule BoardWise.Coaches.QueryParams do
:bullet_lte, :bullet_lte,
:titles, :titles,
:languages, :languages,
:sites,
page_no: 1, page_no: 1,
page_size: 15 page_size: 15
] ]

View File

@ -15,6 +15,7 @@ defmodule BoardWiseWeb.CoachController do
|> override_param(:bullet_lte, params, :integer) |> override_param(:bullet_lte, params, :integer)
|> override_param(:languages, params, :strlist) |> override_param(:languages, params, :strlist)
|> override_param(:titles, params, :strlist) |> override_param(:titles, params, :strlist)
|> override_param(:sites, params, :strlist)
|> override_param(:page_no, params, :integer) |> override_param(:page_no, params, :integer)
|> override_param(:page_size, params, :integer) |> override_param(:page_size, params, :integer)