Add filters on source site.
parent
75e915e71c
commit
c54f832935
|
@ -10,6 +10,7 @@ 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 {
|
||||
|
@ -77,6 +78,10 @@ export function FilterModal({
|
|||
|
||||
// Registration
|
||||
|
||||
const registerSites = register("sites", {
|
||||
required: "Please select at least one site.",
|
||||
})
|
||||
|
||||
const proxyLanguages = register("languages")
|
||||
const registerLanguages: Pick<
|
||||
SelectLanguageProps,
|
||||
|
@ -126,6 +131,21 @@ export function FilterModal({
|
|||
}}
|
||||
>
|
||||
<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):
|
||||
|
@ -162,6 +182,22 @@ export function FilterModal({
|
|||
/>
|
||||
</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">
|
||||
|
@ -208,22 +244,6 @@ export function FilterModal({
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
@ -6,13 +6,16 @@ import type { SearchParams } from "../types/SearchParams"
|
|||
import BulletIcon from "../icons/Bullet"
|
||||
import EnglishIcon from "../icons/English"
|
||||
import FilterIcon from "../icons/Filter"
|
||||
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 {
|
||||
|
@ -75,11 +78,29 @@ const filters: FilterOption[] = [
|
|||
},
|
||||
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",
|
||||
Icon: TrophyIcon,
|
||||
enable: (p) => {
|
||||
p.titles = Object.keys(Title)
|
||||
p.titles = Object.keys(Title) as Title[]
|
||||
return p
|
||||
},
|
||||
isEnabled: (p) => p.titles.length > 0,
|
||||
|
|
|
@ -1,41 +1,77 @@
|
|||
import * as React from "react"
|
||||
import clsx from "clsx"
|
||||
|
||||
type SearchResultProps = {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
src?: string
|
||||
} & React.ComponentPropsWithoutRef<"a">
|
||||
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 (
|
||||
<a
|
||||
<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>
|
||||
</a>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,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
|
|
@ -60,21 +60,9 @@ function SearchResults({ searchParams }: { searchParams: SearchParams }) {
|
|||
{group.map((coach) => (
|
||||
<FadeIn
|
||||
key={`${coach.site}-${coach.username}`}
|
||||
className="flex cursor-pointer flex-col"
|
||||
className="flex flex-col"
|
||||
>
|
||||
<SearchResult
|
||||
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
|
||||
}
|
||||
/>
|
||||
<SearchResult coach={coach} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</React.Fragment>
|
||||
|
|
|
@ -4,6 +4,6 @@ export enum Mode {
|
|||
BULLET = "BULLET",
|
||||
}
|
||||
|
||||
export const getModeName = (m: Mode) => {
|
||||
return m.charAt(0) + m.toLowerCase().slice(1)
|
||||
export const getModeName = (mode: Mode) => {
|
||||
return mode.charAt(0) + mode.toLowerCase().slice(1)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Mode } from "./Mode"
|
||||
import { Site } from "./Site"
|
||||
import { Title } from "./Title"
|
||||
|
||||
export type SearchParams = {
|
||||
|
@ -6,6 +7,7 @@ export type SearchParams = {
|
|||
modes: Mode[]
|
||||
languages: string[]
|
||||
titles: Title[]
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export const FIDE_RATING_MIN = 1500
|
||||
|
@ -16,11 +18,16 @@ export const defaultSearchParams: SearchParams = {
|
|||
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
|
||||
languages: [],
|
||||
titles: [],
|
||||
sites: [Site.CHESSCOM, 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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -50,6 +50,7 @@ defmodule BoardWise.Coaches do
|
|||
:bullet_lte => bullet_lte,
|
||||
:languages => languages,
|
||||
:titles => titles,
|
||||
:sites => sites,
|
||||
:page_no => page_no,
|
||||
:page_size => page_size
|
||||
}) do
|
||||
|
@ -67,17 +68,20 @@ defmodule BoardWise.Coaches do
|
|||
? +
|
||||
? +
|
||||
(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.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),
|
||||
type(^languages, {:array, :string}),
|
||||
c.languages,
|
||||
type(^languages, {:array, :string}),
|
||||
c.title,
|
||||
type(^titles, {:array, :string})
|
||||
type(^titles, {:array, :string}),
|
||||
c.site,
|
||||
type(^sites, {:array, :string})
|
||||
)
|
||||
|> selected_as(:score)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ defmodule BoardWise.Coaches.QueryParams do
|
|||
:bullet_lte,
|
||||
:titles,
|
||||
:languages,
|
||||
:sites,
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
]
|
||||
|
|
|
@ -15,6 +15,7 @@ defmodule BoardWiseWeb.CoachController do
|
|||
|> 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)
|
||||
|
||||
|
|
Loading…
Reference in New Issue