diff --git a/assets/js/react/components/FilterScroll.tsx b/assets/js/react/components/FilterScroll.tsx
index 57c43f2..da460d5 100644
--- a/assets/js/react/components/FilterScroll.tsx
+++ b/assets/js/react/components/FilterScroll.tsx
@@ -4,6 +4,7 @@ import clsx from "clsx"
import type { SearchParams } from "../types/SearchParams"
import FilterIcon from "../icons/Filter"
+import EnglishIcon from "../icons/English"
import RightArrowIcon from "../icons/RightArrow"
import RisingGraphIcon from "../icons/RisingGraph"
import { Button } from "./Button"
@@ -19,11 +20,27 @@ const filters: FilterOption[] = [
{
title: "FIDE 2000+",
Icon: RisingGraphIcon,
- enable: (q) => {
- q.fideRating[0] = Math.max(2000, q.fideRating[0])
- return q
+ enable: (p) => {
+ p.fideRating[0] = Math.max(2000, p.fideRating[0])
+ return p
},
- isEnabled: (q) => q.fideRating[0] >= 2000,
+ isEnabled: (p) => p.fideRating[0] >= 2000,
+ },
+ {
+ title: "English Speaking",
+ Icon: EnglishIcon,
+ enable: (p) => {
+ for (const lang of ["en-US", "en-GB"]) {
+ if (!p.languages.includes(lang)) {
+ p.languages.push(lang)
+ }
+ }
+ return p
+ },
+ // Using `||` doesn't match how `enable` works but this is probably closer
+ // to how people would expect the filter to operate.
+ isEnabled: (p) =>
+ p.languages.includes("en-US") || p.languages.includes("en-GB"),
},
]
diff --git a/assets/js/react/components/Select.tsx b/assets/js/react/components/Select.tsx
new file mode 100644
index 0000000..7fe57b1
--- /dev/null
+++ b/assets/js/react/components/Select.tsx
@@ -0,0 +1,105 @@
+import * as React from "react"
+import clsx from "clsx"
+
+import {
+ Select as BaseSelect,
+ SelectProps,
+ SelectOwnerState,
+} from "@mui/base/Select"
+import {
+ Option as BaseOption,
+ OptionProps,
+ OptionOwnerState,
+} from "@mui/base/Option"
+
+import { FieldContext } from "./FieldSet"
+import { resolveSlotProps } from "../utils/props"
+import { sameWidth } from "../utils/popperjs"
+
+export const Option = React.forwardRef>(
+ function Option(props, ref) {
+ const rootSlotProps = (ownerState: OptionOwnerState) => {
+ const resolved = resolveSlotProps(props.slotProps?.root, ownerState)
+ return {
+ ...resolved,
+ className: clsx(
+ "list-none p-2 rounded-lg cursor-default last-of-type:border-b-0",
+ ownerState.disabled
+ ? "text-slate-400"
+ : "hover:bg-slate-100 hover:text-slate-900",
+ {
+ "font-bold": ownerState.selected,
+ "bg-slate-100 text-slate-900":
+ ownerState.selected || ownerState.highlighted,
+ },
+ resolved?.className
+ ),
+ }
+ }
+
+ return (
+
+ )
+ }
+)
+
+export const Select = React.forwardRef(function Select<
+ TValue extends {},
+ Multiple extends boolean,
+>(
+ props: SelectProps,
+ ref: React.ForwardedRef
+) {
+ const fieldContext = React.useContext(FieldContext)
+ const { disabled = fieldContext?.disabled, slotProps, ...other } = props
+
+ const rootSlotProps = (ownerState: SelectOwnerState) => {
+ const resolved = resolveSlotProps(slotProps?.root, ownerState)
+ return {
+ ...resolved,
+ className: clsx(
+ "text-sm box-border px-3 py-2 rounded-lg text-left bg-white border border-solid text-slate-900 transition-all hover:bg-slate-50 outline-0 shadow shadow-slate-100 after:float-right",
+ ownerState.open ? 'after:content-["▴"]' : 'after:content-["▾"]',
+ ownerState.disabled ? "opacity-60" : "",
+ fieldContext?.error
+ ? "border-amber-800 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-800"
+ : "border-slate-300",
+ resolved?.className
+ ),
+ }
+ }
+
+ const listboxSlotProps = (ownerState: SelectOwnerState) => {
+ const resolved = resolveSlotProps(slotProps?.listbox, ownerState)
+ return {
+ ...resolved,
+ className: clsx(
+ "text-sm p-1.5 my-3 rounded-xl h-60 overflow-auto outline-0 bg-white border border-solid border-slate-200 text-slate-900 shadow shadow-slate-100",
+ resolved?.className
+ ),
+ }
+ }
+
+ const popperSlotProps = (ownerState: SelectOwnerState) => {
+ const resolved = resolveSlotProps(slotProps?.popper, ownerState)
+ return {
+ ...resolved,
+ className: clsx("z-[1000]", resolved?.className),
+ modifiers: [sameWidth],
+ }
+ }
+
+ return (
+
+ )
+})
diff --git a/assets/js/react/components/SelectLanguage.tsx b/assets/js/react/components/SelectLanguage.tsx
new file mode 100644
index 0000000..983d0b0
--- /dev/null
+++ b/assets/js/react/components/SelectLanguage.tsx
@@ -0,0 +1,44 @@
+import * as React from "react"
+import { SelectProps } from "@mui/base/Select"
+
+import { Select, Option } from "./Select"
+import { useFetchLanguages } from "../utils/queries"
+
+export type SelectLanguageProps = SelectProps<{}, boolean>
+
+export const SelectLanguage = React.forwardRef(function SelectLanguage(
+ props: SelectLanguageProps,
+ ref: React.ForwardedRef
+) {
+ const id = React.useId()
+ const [options, setOptions] = React.useState([
+ { value: "", label: "Loading..." },
+ ])
+ const { defaultValue, ...other } = props
+ const { isLoading, data } = useFetchLanguages()
+
+ React.useEffect(() => {
+ if (!data) {
+ return
+ }
+ setOptions(data.map((row) => ({ value: row.code, label: row.name })))
+ }, [data])
+
+ return (
+
+ )
+})
diff --git a/assets/js/react/icons/English.tsx b/assets/js/react/icons/English.tsx
new file mode 100644
index 0000000..1504562
--- /dev/null
+++ b/assets/js/react/icons/English.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+
+const SvgComponent = ({ ...props }) => (
+
+)
+
+export default SvgComponent
diff --git a/assets/js/react/pages/Search.tsx b/assets/js/react/pages/Search.tsx
index c24caa0..37a3e5c 100644
--- a/assets/js/react/pages/Search.tsx
+++ b/assets/js/react/pages/Search.tsx
@@ -1,8 +1,4 @@
import * as React from "react"
-import axios from "axios"
-import { useQuery } from "@tanstack/react-query"
-
-import type { Coach } from "../types/Coach"
import { Container } from "../components/Container"
import { FadeIn, FadeInStagger } from "../components/FadeIn"
@@ -12,15 +8,10 @@ import { FilterScroll } from "../components/FilterScroll"
import { Loading } from "../components/Loading"
import { SearchResult } from "../components/SearchResult"
import { defaultSearchParams } from "../types/SearchParams"
+import { useFetchCoaches } from "../utils/queries"
function SearchResults() {
- const { isLoading, isError, data } = useQuery({
- queryKey: ["coaches"],
- queryFn: async () => {
- const response = await axios.get<{ data: Coach[] }>("/api/coaches/")
- return response.data.data
- },
- })
+ const { isLoading, isError, data } = useFetchCoaches()
if (isLoading) {
return
diff --git a/assets/js/react/types/Coach.ts b/assets/js/react/types/Coach.ts
index 1e18501..8bd72bb 100644
--- a/assets/js/react/types/Coach.ts
+++ b/assets/js/react/types/Coach.ts
@@ -3,6 +3,7 @@ export type Coach = {
username: string
name: string | null
image_url: string | null
+ languages: string[] | null
rapid: number | null
blitz: number | null
bullet: number | null
diff --git a/assets/js/react/types/Language.ts b/assets/js/react/types/Language.ts
new file mode 100644
index 0000000..eefc960
--- /dev/null
+++ b/assets/js/react/types/Language.ts
@@ -0,0 +1,4 @@
+export type Language = {
+ name: string
+ code: string
+}
diff --git a/assets/js/react/types/SearchParams.ts b/assets/js/react/types/SearchParams.ts
index 847d99d..743a657 100644
--- a/assets/js/react/types/SearchParams.ts
+++ b/assets/js/react/types/SearchParams.ts
@@ -1,5 +1,6 @@
export type SearchParams = {
fideRating: [number, number]
+ languages: string[]
}
export const FIDE_RATING_MIN = 1500
@@ -7,4 +8,5 @@ export const FIDE_RATING_MAX = 3200
export const defaultSearchParams: SearchParams = {
fideRating: [FIDE_RATING_MIN, FIDE_RATING_MAX],
+ languages: [],
}
diff --git a/assets/js/react/utils/popperjs.ts b/assets/js/react/utils/popperjs.ts
new file mode 100644
index 0000000..50f66b8
--- /dev/null
+++ b/assets/js/react/utils/popperjs.ts
@@ -0,0 +1,16 @@
+import { type Modifier } from "@popperjs/core"
+
+export const sameWidth: Partial> = {
+ name: "sameWidth",
+ phase: "beforeWrite",
+ enabled: true,
+ requires: ["computeStyles"],
+ fn: ({ state }) => {
+ state.styles.popper.width = `${state.rects.reference.width}px`
+ },
+ effect: ({ state }) => {
+ if ("offsetWidth" in state.elements.reference) {
+ state.elements.popper.style.width = `${state.elements.reference.offsetWidth}px`
+ }
+ },
+}
diff --git a/assets/js/react/utils/queries.ts b/assets/js/react/utils/queries.ts
new file mode 100644
index 0000000..f57fb20
--- /dev/null
+++ b/assets/js/react/utils/queries.ts
@@ -0,0 +1,25 @@
+import axios from "axios"
+import { useQuery } from "@tanstack/react-query"
+
+import type { Coach } from "../types/Coach"
+import type { Language } from "../types/Language"
+
+export const useFetchCoaches = () => {
+ return useQuery({
+ queryKey: ["api", "coaches"],
+ queryFn: async () => {
+ const response = await axios.get<{ data: Coach[] }>("/api/coaches/")
+ return response.data.data
+ },
+ })
+}
+
+export const useFetchLanguages = () => {
+ return useQuery({
+ queryKey: ["api", "languages"],
+ queryFn: async () => {
+ const response = await axios.get<{ data: Language[] }>("/api/languages/")
+ return response.data.data
+ },
+ })
+}
diff --git a/assets/package-lock.json b/assets/package-lock.json
index f56f65a..132afb8 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@mui/base": "^5.0.0-beta.25",
+ "@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.12.2",
"axios": "^1.6.2",
"clsx": "^2.0.0",
diff --git a/assets/package.json b/assets/package.json
index ca357ae..dd74f3c 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -3,6 +3,7 @@
"version": "0.1.0",
"dependencies": {
"@mui/base": "^5.0.0-beta.25",
+ "@popperjs/core": "^2.11.8",
"@tanstack/react-query": "^5.12.2",
"axios": "^1.6.2",
"clsx": "^2.0.0",
diff --git a/lib/boardwise/coaches.ex b/lib/boardwise/coaches.ex
index 9bb1a09..d4aa51c 100644
--- a/lib/boardwise/coaches.ex
+++ b/lib/boardwise/coaches.ex
@@ -22,7 +22,6 @@ defmodule BoardWise.Coaches do
def list_coaches do
Coach
|> limit(6)
- |> where(site: "lichess")
|> Repo.all(prefix: @prefix)
end
diff --git a/lib/boardwise/coaches/coach.ex b/lib/boardwise/coaches/coach.ex
index 43f5c7c..a61be17 100644
--- a/lib/boardwise/coaches/coach.ex
+++ b/lib/boardwise/coaches/coach.ex
@@ -19,6 +19,7 @@ defmodule BoardWise.Coaches.Coach do
field :username, :string
field :name, :string
field :image_url, :string
+ field :languages, {:array, :string}
field :blitz, :integer
field :bullet, :integer
field :rapid, :integer
diff --git a/lib/boardwise/languages.ex b/lib/boardwise/languages.ex
new file mode 100644
index 0000000..a19b6c0
--- /dev/null
+++ b/lib/boardwise/languages.ex
@@ -0,0 +1,106 @@
+defmodule BoardWise.Languages do
+ @moduledoc """
+ The Languages context.
+ """
+
+ import Ecto.Query, warn: false
+ alias BoardWise.Repo
+
+ alias BoardWise.Languages.Language
+
+ @prefix "coach_scraper"
+
+ @doc """
+ Returns the list of languages.
+
+ ## Examples
+
+ iex> list_languages()
+ [%Language{}, ...]
+
+ """
+ def list_languages do
+ Repo.all(Language, prefix: @prefix)
+ end
+
+ @doc """
+ Gets a single language.
+
+ Raises `Ecto.NoResultsError` if the Language does not exist.
+
+ ## Examples
+
+ iex> get_language!(123)
+ %Language{}
+
+ iex> get_language!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_language!(id), do: Repo.get!(Language, id, prefix: @prefix)
+
+ @doc """
+ Creates a language.
+
+ ## Examples
+
+ iex> create_language(%{field: value})
+ {:ok, %Language{}}
+
+ iex> create_language(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_language(attrs \\ %{}) do
+ %Language{}
+ |> Language.changeset(attrs)
+ |> Repo.insert(prefix: @prefix)
+ end
+
+ @doc """
+ Updates a language.
+
+ ## Examples
+
+ iex> update_language(language, %{field: new_value})
+ {:ok, %Language{}}
+
+ iex> update_language(language, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_language(%Language{} = language, attrs) do
+ language
+ |> Language.changeset(attrs)
+ |> Repo.update(prefix: @prefix)
+ end
+
+ @doc """
+ Deletes a language.
+
+ ## Examples
+
+ iex> delete_language(language)
+ {:ok, %Language{}}
+
+ iex> delete_language(language)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_language(%Language{} = language) do
+ Repo.delete(language, prefix: @prefix)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking language changes.
+
+ ## Examples
+
+ iex> change_language(language)
+ %Ecto.Changeset{data: %Language{}}
+
+ """
+ def change_language(%Language{} = language, attrs \\ %{}) do
+ Language.changeset(language, attrs)
+ end
+end
diff --git a/lib/boardwise/languages/language.ex b/lib/boardwise/languages/language.ex
new file mode 100644
index 0000000..d29f030
--- /dev/null
+++ b/lib/boardwise/languages/language.ex
@@ -0,0 +1,17 @@
+defmodule BoardWise.Languages.Language do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "languages" do
+ field :code, :string
+ field :name, :string
+ end
+
+ @doc false
+ def changeset(language, attrs) do
+ language
+ |> cast(attrs, [:code, :name])
+ |> validate_required([:code, :name])
+ |> unique_constraint(:code_unique, name: :code_unique)
+ end
+end
diff --git a/lib/boardwise_web/controllers/coach_json.ex b/lib/boardwise_web/controllers/coach_json.ex
index 513f636..a1c8d97 100644
--- a/lib/boardwise_web/controllers/coach_json.ex
+++ b/lib/boardwise_web/controllers/coach_json.ex
@@ -14,6 +14,7 @@ defmodule BoardWiseWeb.CoachJSON do
username: coach.username,
name: coach.name,
image_url: coach.image_url,
+ languages: coach.languages,
rapid: coach.rapid,
blitz: coach.blitz,
bullet: coach.bullet
diff --git a/lib/boardwise_web/controllers/language_controller.ex b/lib/boardwise_web/controllers/language_controller.ex
new file mode 100644
index 0000000..2f74708
--- /dev/null
+++ b/lib/boardwise_web/controllers/language_controller.ex
@@ -0,0 +1,10 @@
+defmodule BoardWiseWeb.LanguageController do
+ use BoardWiseWeb, :controller
+
+ alias BoardWise.Languages
+
+ def index(conn, _params) do
+ langs = Languages.list_languages()
+ render(conn, :index, langs: langs)
+ end
+end
diff --git a/lib/boardwise_web/controllers/language_json.ex b/lib/boardwise_web/controllers/language_json.ex
new file mode 100644
index 0000000..8287a1a
--- /dev/null
+++ b/lib/boardwise_web/controllers/language_json.ex
@@ -0,0 +1,17 @@
+defmodule BoardWiseWeb.LanguageJSON do
+ alias BoardWise.Languages.Language
+
+ @doc """
+ Renders a list of coaches.
+ """
+ def index(%{langs: langs}) do
+ %{data: for(lang <- langs, do: data(lang))}
+ end
+
+ defp data(%Language{} = lang) do
+ %{
+ code: lang.code,
+ name: lang.name
+ }
+ end
+end
diff --git a/lib/boardwise_web/router.ex b/lib/boardwise_web/router.ex
index ba622cd..07ab82c 100644
--- a/lib/boardwise_web/router.ex
+++ b/lib/boardwise_web/router.ex
@@ -26,6 +26,7 @@ defmodule BoardWiseWeb.Router do
pipe_through :api
get "/coaches", CoachController, :index
+ get "/languages", LanguageController, :index
end
# Other scopes may use custom stacks.
diff --git a/priv/repo/migrations/20231205212321_languages.exs b/priv/repo/migrations/20231205212321_languages.exs
new file mode 100644
index 0000000..4408bbf
--- /dev/null
+++ b/priv/repo/migrations/20231205212321_languages.exs
@@ -0,0 +1,11 @@
+defmodule BoardWise.Repo.Migrations.Languages do
+ use Ecto.Migration
+
+ @prefix "coach_scraper"
+
+ def change do
+ alter table(:export, prefix: @prefix) do
+ add :languages, {:array, :string}
+ end
+ end
+end
diff --git a/priv/repo/migrations/20231205220353_create_languages.exs b/priv/repo/migrations/20231205220353_create_languages.exs
new file mode 100644
index 0000000..d0faf84
--- /dev/null
+++ b/priv/repo/migrations/20231205220353_create_languages.exs
@@ -0,0 +1,19 @@
+defmodule BoardWise.Repo.Migrations.CreateLanguages do
+ use Ecto.Migration
+
+ @prefix "coach_scraper"
+
+ def change do
+ create table(:languages, prefix: @prefix) do
+ add :code, :string, null: false
+ add :name, :string, null: false
+ end
+
+ create unique_index(
+ :languages,
+ [:code],
+ prefix: @prefix,
+ name: "code_unique"
+ )
+ end
+end
diff --git a/test/boardwise/languages_test.exs b/test/boardwise/languages_test.exs
new file mode 100644
index 0000000..fc97180
--- /dev/null
+++ b/test/boardwise/languages_test.exs
@@ -0,0 +1,61 @@
+defmodule BoardWise.LanguagesTest do
+ use BoardWise.DataCase
+
+ alias BoardWise.Languages
+
+ describe "languages" do
+ alias BoardWise.Languages.Language
+
+ import BoardWise.LanguagesFixtures
+
+ @invalid_attrs %{code: nil, name: nil}
+
+ test "list_languages/0 returns all languages" do
+ language = language_fixture()
+ assert Languages.list_languages() == [language]
+ end
+
+ test "get_language!/1 returns the language with given id" do
+ language = language_fixture()
+ assert Languages.get_language!(language.id) == language
+ end
+
+ test "create_language/1 with valid data creates a language" do
+ valid_attrs = %{code: "some code", name: "some name"}
+
+ assert {:ok, %Language{} = language} = Languages.create_language(valid_attrs)
+ assert language.code == "some code"
+ assert language.name == "some name"
+ end
+
+ test "create_language/1 with invalid data returns error changeset" do
+ assert {:error, %Ecto.Changeset{}} = Languages.create_language(@invalid_attrs)
+ end
+
+ test "update_language/2 with valid data updates the language" do
+ language = language_fixture()
+ update_attrs = %{code: "some updated code", name: "some updated name"}
+
+ assert {:ok, %Language{} = language} = Languages.update_language(language, update_attrs)
+ assert language.code == "some updated code"
+ assert language.name == "some updated name"
+ end
+
+ test "update_language/2 with invalid data returns error changeset" do
+ language = language_fixture()
+ assert {:error, %Ecto.Changeset{}} = Languages.update_language(language, @invalid_attrs)
+ assert language == Languages.get_language!(language.id)
+ end
+
+ test "delete_language/1 deletes the language" do
+ language = language_fixture()
+ assert {:ok, %Language{}} = Languages.delete_language(language)
+ assert_raise Ecto.NoResultsError, fn -> Languages.get_language!(language.id) end
+ end
+
+ test "change_language/1 returns a language changeset" do
+ language = language_fixture()
+ assert %Ecto.Changeset{} = Languages.change_language(language)
+ end
+ end
+end
diff --git a/test/support/fixtures/languages_fixtures.ex b/test/support/fixtures/languages_fixtures.ex
new file mode 100644
index 0000000..da9c865
--- /dev/null
+++ b/test/support/fixtures/languages_fixtures.ex
@@ -0,0 +1,21 @@
+defmodule BoardWise.LanguagesFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `BoardWise.Languages` context.
+ """
+
+ @doc """
+ Generate a language.
+ """
+ def language_fixture(attrs \\ %{}) do
+ {:ok, language} =
+ attrs
+ |> Enum.into(%{
+ code: "some code",
+ name: "some name"
+ })
+ |> BoardWise.Languages.create_language()
+
+ language
+ end
+end