Reorganise code - use src folder

This commit is contained in:
Timothy Armes
2023-10-11 08:45:36 +02:00
committed by Owen Rees
parent 2dc5f477c9
commit b5061b1c78
89 changed files with 70 additions and 69 deletions
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useMemo, useRef } from "react";
import { useSetAtom } from "jotai";
import L, { LatLngLiteral } from "leaflet";
import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet/dist/leaflet.css";
import { useFormContext, useWatch } from "react-hook-form";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import { mapBoundsAtom } from "@/atoms";
import MapEvents from "@/components/MapEvents";
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const Map = () => {
const { control, setValue } = useFormContext();
const latValue = useWatch({ control, name: "tournament.coordinates.0" });
const lngValue = useWatch({ control, name: "tournament.coordinates.1" });
const setMapBounds = useSetAtom(mapBoundsAtom);
const markerRef = useRef<L.Marker | null>(null);
const eventHandlers = useMemo(
() => ({
dragend() {
const marker = markerRef.current;
if (marker != null) {
const position = marker.getLatLng();
setValue(
"tournament.coordinates.0",
Math.round((position.lat + Number.EPSILON) * 10000) / 10000,
);
setValue(
"tournament.coordinates.1",
Math.round((position.lng + Number.EPSILON) * 10000) / 10000,
);
}
},
}),
[setValue],
);
return (
<MapContainer
center={center}
zoom={5}
scrollWheelZoom={false}
style={{ height: "600px", flexGrow: 1 }}
ref={(map) => {
if (map) {
setMapBounds(map.getBounds());
}
}}
>
<MapEvents />
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker
position={{ lat: latValue, lng: lngValue }}
draggable={true}
ref={markerRef}
eventHandlers={eventHandlers}
/>
</MapContainer>
);
};
export default Map;
@@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { clearMessage } from "@/components/InfoMessage";
import InfoMessage from "@/components/InfoMessage";
import LoadingMap from "@/components/LoadingMap";
import { DateField } from "@/components/form/DateField";
import { SelectField } from "@/components/form/SelectField";
import { SwitchField } from "@/components/form/SwitchField";
import { TextAreaField } from "@/components/form/TextAreaField";
import { TextField } from "@/components/form/TextField";
import { addTournamentSchema } from "@/schemas";
import { TimeControl } from "@/types";
import { trpc } from "@/utils/trpc";
type TournamentFormValues = z.infer<typeof addTournamentSchema>;
const Map = dynamic(() => import("./Map"), {
ssr: false,
loading: LoadingMap,
});
const TournamentForm = () => {
const t = useTranslations("AddTournament");
const at = useTranslations("App");
const addTournament = trpc.addTournament.useMutation();
const [responseMessage, setResponseMessage] = useState({
isSuccessful: false,
message: "",
});
const form = useForm<TournamentFormValues>({
resolver: zodResolver(addTournamentSchema),
defaultValues: {
tournament: {
norm_tournament: false,
coordinates: [47.0844, 2.3964],
},
},
});
const onSubmit = async (data: TournamentFormValues) => {
try {
await addTournament.mutateAsync(data);
setResponseMessage({
isSuccessful: true,
message: t("success"),
});
clearMessage(setResponseMessage);
form.reset();
} catch (err: unknown) {
console.log(err);
setResponseMessage({
isSuccessful: true,
message: t("failure"),
});
clearMessage(setResponseMessage);
}
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid grid-cols-4 items-start gap-6">
<div className="col-span-4">
<TextField
name="tournament.tournament"
control={form.control}
label={t("tournamentNameLabel")}
placeholder={t("tournamentNamePlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<DateField
name="tournament.date"
control={form.control}
label={t("dateLabel")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.url"
control={form.control}
label={t("urlLabel")}
placeholder={t("urlPlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<SelectField
name="tournament.time_control"
control={form.control}
label={t("tcLabel")}
options={[
TimeControl.Classic,
TimeControl.Rapid,
TimeControl.Blitz,
].map((tc) => ({
value: tc,
label: at("timeControlEnum", { tc }),
}))}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<SwitchField
name="tournament.norm_tournament"
control={form.control}
label={t("normLabel")}
/>
</div>
<div className="col-span-4">
<TextField
name="tournament.address"
control={form.control}
label={t("addressLabel")}
placeholder={t("addressPlaceholder")}
required
/>
</div>
<div className="col-span-4 sm:col-span-2">
<TextField
name="tournament.town"
control={form.control}
label={t("townLabel")}
placeholder={t("townPlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.department"
control={form.control}
label={t("departmentLabel")}
placeholder={t("departmentPlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.country"
control={form.control}
label={t("countryLabel")}
placeholder={t("countryPlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="name"
control={form.control}
label={t("yourNameLabel")}
placeholder={t("yourNamePlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="email"
control={form.control}
label={t("yourEmailLabel")}
placeholder={t("yourEmailPlaceholder")}
required
/>
</div>
<div className="col-span-4 row-span-2 sm:col-span-2">
<TextAreaField
name="message"
control={form.control}
label={t("messageLabel")}
rows={6}
placeholder={t("messagePlaceholder")}
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.coordinates.0"
control={form.control}
label={t("latLabel")}
type="number"
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.coordinates.1"
control={form.control}
label={t("lngLabel")}
type="number"
required
/>
</div>
<section id="map" className="z-0 col-span-4 flex h-auto">
<Map />
</section>
</div>
<button
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</button>
<button
onClick={() => form.reset()}
type="button"
className="ml-4 rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{t("clearForm")}
</button>
<InfoMessage responseMessage={responseMessage} />
</form>
</FormProvider>
);
};
export default TournamentForm;
+26
View File
@@ -0,0 +1,26 @@
import { useTranslations } from "next-intl";
import TournamentForm from "./TournamentForm";
export default function Contact() {
const t = useTranslations("AddTournament");
return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="mx-auto max-w-screen-md px-4 py-8 lg:py-16">
<h2
className="mb-4 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2"
>
{t("title")}
</h2>
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
{t("info")}
</p>
<TournamentForm />
</div>
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useCallback, useMemo } from "react";
import { useAtomValue } from "jotai";
import L, { LatLngLiteral } from "leaflet";
import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet.smooth_marker_bouncing";
import "leaflet/dist/leaflet.css";
import { countBy, groupBy } from "lodash";
import { clubsAtom } from "@/atoms";
import { Map, MapMarker } from "@/components/Map";
import { ClubMarker } from "./ClubMarker";
const ClubMap = () => {
const clubs = useAtomValue(clubsAtom);
const markers: MapMarker[] = useMemo(
() =>
clubs.map((club) => {
return {
markerId: club.id,
tableIds: [club.id],
component: <ClubMarker key={club.id} club={club} />,
};
}),
[clubs],
);
return <Map markers={markers} />;
};
export default ClubMap;
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import { useSetAtom } from "jotai";
import L from "leaflet";
import { Marker, MarkerProps, Popup } from "react-leaflet";
import { debouncedHoveredMapIdAtom } from "@/atoms";
import type { MarkerRef } from "@/components/Map";
import { TimeControlColours } from "@/constants";
import type { BouncingMarker } from "@/leafletTypes";
import { Club } from "@/types";
type ClubMarkerProps = {
club: Club;
} & Omit<MarkerProps, "position">;
export const ClubMarker = forwardRef<MarkerRef, ClubMarkerProps>(
({ club, ...markerProps }, ref) => {
const markerRef = useRef<L.Marker<any> | null>(null);
useImperativeHandle(
ref,
() => ({
getMarker: () => markerRef.current!,
bounce: () => {
const bouncingMarker = markerRef.current as BouncingMarker;
bouncingMarker.setBouncingOptions({ contractHeight: 0 });
bouncingMarker.bounce();
},
}),
[],
);
const { id, latLng } = club;
const setHoveredMapId = useSetAtom(debouncedHoveredMapIdAtom);
const iconOptions = useMemo(
() =>
new L.DivIcon({
html: `
<svg x="0px" y="0px" viewBox="0 0 365 560" enable-background="new 0 0 365 560" xml:space="preserve">
<g><circle fill="#FFFFFF" cx="182" cy="180" r="70" /></g>
<g><path stroke="#666666" stroke-width="10" fill="${TimeControlColours.Classic}" d="M182.9,551.7c0,0.1,0.2,0.3,0.2,0.3S358.3,283,358.3,194.6c0-130.1-88.8-186.7-175.4-186.9 C96.3,7.9,7.5,64.5,7.5,194.6c0,88.4,175.3,357.4,175.3,357.4S182.9,551.7,182.9,551.7z M122.2,187.2c0-33.6,27.2-60.8,60.8-60.8 c33.6,0,60.8,27.2,60.8,60.8S216.5,248,182.9,248C149.4,248,122.2,220.8,122.2,187.2z"/></g>
</svg>
`,
className: "",
iconSize: [24, 40],
iconAnchor: [12, 40],
popupAnchor: [1, -40],
}),
[],
);
return (
<Marker
ref={markerRef}
position={latLng}
icon={iconOptions}
eventHandlers={{
mouseover: () => setHoveredMapId(id),
mouseout: () => setHoveredMapId(null),
}}
{...markerProps}
>
<Popup maxWidth={10000}>
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
<div className="flex flex-col gap-0">
<div>{club.name}</div>
{club.address && <div>{club.address}</div>}
{club.website && (
<div>
<a
href={club.website}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.website}
</a>
</div>
)}
{club.url && (
<div>
<a
href={club.url}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.url}
</a>
</div>
)}
{club.email && (
<div>
<a
href={`mailto:${club.email}`}
className="text-primary hover:text-primary-800"
>
{club.email}
</a>
</div>
)}
</div>
</div>
</Popup>
</Marker>
);
},
);
ClubMarker.displayName = "ClubMarker";
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useEffect } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl";
import { FaExternalLinkAlt } from "react-icons/fa";
import { twMerge } from "tailwind-merge";
import {
debouncedHoveredListIdAtom,
debouncedHoveredMapIdAtom,
filteredClubsListAtom,
hoveredMapIdAtom,
syncVisibleAtom,
} from "@/atoms";
import ScrollToTopButton from "@/components/ScrollToTopButton";
import SearchBar from "@/components/SearchBar";
import { useBreakpoint } from "@/hooks/tailwind";
const ClubTable = () => {
const t = useTranslations("Clubs");
const at = useTranslations("App");
const filteredClubs = useAtomValue(filteredClubsListAtom);
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
const hoveredMapId = useAtomValue(hoveredMapIdAtom);
const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom);
const setHoveredListId = useSetAtom(debouncedHoveredListIdAtom);
const isLg = useBreakpoint("lg");
useEffect(() => {
if (!isLg || debouncedHoveredMapId === null) return;
const clubRow = document.querySelector(
`[data-group-id="${debouncedHoveredMapId}"]`,
);
clubRow?.scrollIntoView({ behavior: "smooth" });
}, [debouncedHoveredMapId, isLg]);
return (
<section
className="club-table grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-content lg:overflow-y-scroll lg:pb-0"
id="listing"
>
<div className="z-10 flex w-full flex-wrap items-center justify-between gap-3 p-3">
<SearchBar />
<div className="flex flex-col gap-0 text-gray-900 dark:text-white">
<label>
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
checked={syncVisible}
onChange={() => setSyncVisible(!syncVisible)}
/>
{t("syncWithMapCheckbox")}
</label>
</div>
</div>
<ScrollToTopButton />
<div className="overflow-x-scroll bg-red-50">
<table className="relative min-w-full table-fixed text-center text-xs lg:min-w-full">
<thead>
<tr>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("name")}
</th>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("contact")}
</th>
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("ffe")}
</th>
</tr>
</thead>
<tbody>
{filteredClubs.length === 0 ? (
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
<td colSpan={4} className="p-3">
{t("noneFound")}
</td>
</tr>
) : (
filteredClubs.map((club) => (
<tr
key={club.id}
id={club.id}
onMouseEnter={() => setHoveredListId(club.id)}
onMouseLeave={() => setHoveredListId(null)}
className={twMerge(
"scroll-m-20 bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900",
hoveredMapId === club.id && "bg-gray-200 dark:bg-gray-900",
)}
>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{club.name}
</td>
<td className="px-1 py-2 text-left sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{club.address && <div>{club.address}</div>}
{club.website && (
<div>
<a
href={club.website}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.website}
</a>
</div>
)}
{club.email && (
<div>
<a
href={`mailto:${club.email}`}
className="text-primary hover:text-primary-800"
>
{club.email}
</a>
</div>
)}
</td>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
<div className="flex justify-center">
<a
href={club.url}
target="_blank"
className="text-primary hover:text-primary-800"
>
<FaExternalLinkAlt />
</a>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
);
};
export default ClubTable;
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useHydrateAtoms } from "jotai/utils";
import dynamic from "next/dynamic";
import { clubsAtom } from "@/atoms";
import LoadingMap from "@/components/LoadingMap";
import { Club } from "@/types";
import ClubTable from "./ClubTable";
type ClubsDisplayProps = {
clubs: Club[];
};
/**
* Imports the club map component, ensuring CSR only.
* @remarks SSR is not supported by react-leaflet
*/
const ClubMap = dynamic(() => import("./ClubMap"), {
ssr: false,
loading: LoadingMap,
});
export default function ClubsDisplay({ clubs }: ClubsDisplayProps) {
useHydrateAtoms([[clubsAtom, clubs]]);
return (
<main className="relative grid h-full w-full lg:grid-cols-2">
<div>
<ClubMap />
</div>
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
<ClubTable />
</div>
</main>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { groupBy } from "lodash";
import clientPromise from "@/lib/mongodb";
import { Club, ClubData } from "@/types";
import { errorLog } from "@/utils/logger";
import ClubsDisplay from "./ClubsDisplay";
export const revalidate = 3600; // Revalidate cache every 6 hours
const getClubs = async () => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
const data = await db
.collection("clubs")
.find<ClubData>({})
.sort({ name: 1 })
.toArray();
return data
.filter((c) => !!c.coordinates)
.map<Club>((club) => {
return {
id: club._id.toString(),
name: club.name,
url: club.url,
address: club.address,
email: club.email,
website: club.website,
latLng: { lat: club.coordinates[0], lng: club.coordinates[1] },
};
});
} catch (error) {
errorLog(error);
throw new Error("Error fetching club data");
}
};
export default async function Clubs() {
const clubs = await getClubs();
return <ClubsDisplay clubs={clubs} />;
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useTranslations } from "next-intl";
import { FaGithub, FaRegEnvelope } from "react-icons/fa";
import { Link, usePathname } from "@/utils/navigation";
import ThemeSwitcher from "./ThemeSwitcher";
export default function Footer() {
const t = useTranslations("Footer");
const pathname = usePathname();
return (
<footer
className="fixed bottom-0 z-30 flex h-12 w-[100vw] flex-col items-center justify-center justify-items-center bg-primary-600 px-5 py-2 text-white dark:bg-gray-700"
data-test="footer"
>
<div className="flex items-center py-2 hover:[&_a]:opacity-80">
<a
href="https://github.com/TheRealOwenRees/echecsfrance"
target="_blank"
aria-label={t("githubAria")}
className="mr-4"
>
<FaGithub />
</a>
<Link href="/contact-us" aria-label={t("contactAria")} className="mr-4">
<FaRegEnvelope />
</Link>
<div className="mr-4 space-x-2 text-xs">
<Link href={pathname} locale="fr">
FR
</Link>
<span>|</span>
<Link href={pathname} locale="en">
EN
</Link>
</div>
<div className="text-xs hover:opacity-80">
<ThemeSwitcher />
</div>
</div>
</footer>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useAtom } from "jotai";
import { twMerge } from "tailwind-merge";
import { burgerMenuIsOpenAtom } from "@/atoms";
import HamburgerMenu from "./HamburgerMenu";
const Hamburger = () => {
const [burgerMenuIsOpen, setBurgerMenuIsOpen] = useAtom(burgerMenuIsOpenAtom);
return (
<>
<div
className="hamburger-button relative z-[99999] space-y-2"
data-test="hamburger-button"
onClick={() => setBurgerMenuIsOpen(!burgerMenuIsOpen)}
>
<div
className={twMerge(
"h-0.5 w-8 bg-gray-600 transition-all duration-300 ease-in-out dark:bg-white",
burgerMenuIsOpen &&
"translate-x-[1px] translate-y-2.5 rotate-45 bg-white",
)}
></div>
<div
className={twMerge(
"h-0.5 w-8 bg-gray-600 transition-transform duration-300 ease-linear dark:bg-white",
burgerMenuIsOpen && "scale-0 bg-white opacity-0",
)}
></div>
<div
className={twMerge(
"h-0.5 w-8 bg-gray-600 transition-transform duration-300 ease-in-out dark:bg-white",
burgerMenuIsOpen && "-translate-y-2.5 -rotate-45 bg-white",
)}
></div>
</div>
<HamburgerMenu />
</>
);
};
export default Hamburger;
@@ -0,0 +1,59 @@
import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react";
import { useAtom } from "jotai";
import { useTranslations } from "next-intl";
import { twMerge } from "tailwind-merge";
import { burgerMenuIsOpenAtom } from "@/atoms";
import { Link } from "@/utils/navigation";
const HamburgerMenu = () => {
const t = useTranslations("Nav");
const [burgerMenuIsOpen, setBurgerMenuIsOpen] = useAtom(burgerMenuIsOpenAtom);
const closeMenu = () => setBurgerMenuIsOpen(false);
return (
<div
className={twMerge(
"fixed right-0 top-0 z-[9999] h-[calc(100svh-3rem)] w-full",
"flex items-center justify-center bg-primary-600 transition-transform duration-200 ease-linear dark:bg-gray-600 md:hidden",
!burgerMenuIsOpen && "translate-x-full",
)}
>
<ul className="list-reset text-white">
<li className="py-5 text-center text-xl">
<Link
onClick={closeMenu}
href="/tournaments"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
>
{t("tournaments")}
</Link>
</li>
<li className="py-5 text-center text-xl">
<Link
onClick={closeMenu}
href="/clubs"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
>
{t("clubs")}
</Link>
</li>
<li className="py-5 text-center text-xl">
<Link
onClick={closeMenu}
href="/elo"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
>
{t("elo")}
</Link>
</li>
</ul>
</div>
);
};
export default HamburgerMenu;
+55
View File
@@ -0,0 +1,55 @@
import { useTranslations } from "next-intl";
import { Link } from "@/utils/navigation";
import Hamburger from "./Hamburger";
export default function Navbar() {
const t = useTranslations("Nav");
const links = [
{ title: t("tournaments"), route: "/tournaments" },
{ title: t("clubs"), route: "/clubs" },
{ title: t("elo"), route: "/elo" },
] as const;
return (
<nav
className="relative mt-0 h-16 w-full overflow-x-clip border-b-[1px] bg-white px-5 dark:border-gray-700 dark:bg-gray-800"
data-test="navbar"
>
<div className="container mx-auto flex h-full items-center justify-between">
<Link
className="font-extrabold text-gray-900 no-underline hover:no-underline dark:text-white"
href="/"
>
<span className="font-title text-2xl text-gray-800 dark:text-white">
{t("title")}
</span>
</Link>
<div className="md:hidden" data-test="mobile-menu">
<Hamburger />
</div>
<div
className="hidden h-full justify-center md:flex md:w-1/2 md:justify-end"
data-test="desktop-menu"
>
<ul className="list-reset flex h-full flex-1 items-center gap-14 text-gray-900 no-underline dark:text-white md:flex-none">
{links.map(({ title, route }) => (
<li key={route} className="h-full">
<Link
className="inline-flex h-full items-center border-b-4 border-t-4 border-transparent transition-all duration-300 ease-in-out hover:border-b-primary-600"
href={route}
>
{title}
</Link>
</li>
))}
</ul>
</div>
</div>
</nav>
);
}
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
import useDarkMode from "@/hooks/useDarkMode";
const ThemeSwitcher = () => {
const [mounted, setMounted] = useState(false);
const [colorTheme, setTheme] = useDarkMode();
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div className="cursor-pointer">
{colorTheme === "light" ? (
<div data-test="toggle-dark" onClick={() => setTheme("light")}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="inline-block h-4 w-4"
>
<path
fillRule="evenodd"
d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z"
clipRule="evenodd"
/>
</svg>
</div>
) : (
<div data-test="toggle" onClick={() => setTheme("dark")}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="inline-block h-4 w-4"
>
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
</svg>
</div>
)}
</div>
);
};
export default ThemeSwitcher;
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import InfoMessage from "@/components/InfoMessage";
import { clearMessage } from "@/components/InfoMessage";
import { TextAreaField } from "@/components/form/TextAreaField";
import { TextField } from "@/components/form/TextField";
import { contactUsSchema } from "@/schemas";
import { trpc } from "@/utils/trpc";
type TournamentFormValues = z.infer<typeof contactUsSchema>;
const ContactForm = () => {
const t = useTranslations("Contact");
const contactUs = trpc.contactUs.useMutation();
const [responseMessage, setResponseMessage] = useState({
isSuccessful: false,
message: "",
});
const form = useForm<TournamentFormValues>({
resolver: zodResolver(contactUsSchema),
});
const onSubmit = async (data: TournamentFormValues) => {
try {
await contactUs.mutateAsync(data);
setResponseMessage({
isSuccessful: true,
message: t("success"),
});
clearMessage(setResponseMessage);
form.reset();
} catch (err: unknown) {
console.log(err);
setResponseMessage({
isSuccessful: true,
message: t("failure"),
});
clearMessage(setResponseMessage);
}
};
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<TextField
name="email"
control={form.control}
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
required
/>
<TextField
name="subject"
control={form.control}
label={t("subjectLabel")}
placeholder={t("subjectPlaceholder")}
required
/>
<TextAreaField
name="message"
control={form.control}
rows={6}
label={t("messageLabel")}
placeholder={t("messagePlaceholder")}
required
/>
<button
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{form.formState.isSubmitting ? t("sending") : t("sendButton")}
</button>
<InfoMessage responseMessage={responseMessage} />
</form>
</FormProvider>
);
};
export default ContactForm;
+24
View File
@@ -0,0 +1,24 @@
import { useTranslations } from "next-intl";
import ContactForm from "./ContactForm";
export default function Contact() {
const t = useTranslations("Contact");
return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="mx-auto max-w-screen-md px-4 py-8 lg:py-16">
<h2
className="mb-4 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2"
>
{t("title")}
</h2>
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
{t("info")}
</p>
<ContactForm />
</div>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Disclosure } from "@headlessui/react";
import { useTranslations } from "next-intl";
import { IoChevronForward } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
type KFactorProps = {
className?: string;
};
export const KFactor = ({ className }: KFactorProps) => {
const t = useTranslations("Elo");
return (
<Disclosure>
<Disclosure.Button
className={twMerge(
"flex items-center text-sm text-gray-500 dark:text-neutral-400",
className,
)}
>
<IoChevronForward className="mr-2 h-4 w-4 transition-transform ui-open:rotate-90 ui-open:transform" />
{t("kFactorTitle")}
</Disclosure.Button>
<Disclosure.Panel className="mt-4">
<p className="text-sm text-gray-500 dark:text-neutral-400">
{t("kFactorInfo1")}
</p>
<p className="mt-4 text-sm text-gray-500 dark:text-neutral-400">
{t("kFactorInfo2")}
</p>
<ul className="mb-8 ml-4 mt-3 list-outside list-disc">
{(
[
"kFactorInfo3",
"kFactorInfo4",
"kFactorInfo5",
"kFactorInfo6",
"kFactorInfo7",
] as const
).map((key) => (
<li
key={key}
className="mt-2 text-sm text-gray-500 dark:text-neutral-400"
>
{t.rich(key, { b: (str) => <b>{str}</b> })}
</li>
))}
</ul>
</Disclosure.Panel>
</Disclosure>
);
};
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { last } from "lodash";
import { useTranslations } from "next-intl";
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
import { IoAdd, IoCloseOutline } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { RadioGroupField } from "@/components/form/RadioGroupField";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
import { getNewRating } from "@/utils/eloCalculator";
import { KFactor } from "./KFactor";
const resultsSchema = z.object({
currentElo: z.number().int().positive(),
kFactor: z.enum(["40", "30", "20", "15", "10"]),
games: z.array(
z.object({
opponentElo: z.number().int().positive(),
result: z.enum(["win", "draw", "loss"]),
}),
),
});
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
type EloFormValues = DeepPartial<z.infer<typeof resultsSchema>>;
export const ManualEloForm = () => {
const t = useTranslations("Elo");
const form = useForm<EloFormValues>({
resolver: zodResolver(resultsSchema),
defaultValues: {
kFactor: "20",
games: [{}],
},
});
const {
fields: gameFields,
append: appendGame,
remove: removeGame,
} = useFieldArray({
control: form.control,
name: "games",
});
const onSubmit = async (data: EloFormValues) => {};
const [currentElo, kFactor, games] = form.watch([
"currentElo",
"kFactor",
"games",
]);
type Deltas = {
rating: number;
deltas: { rating: number; delta: number | undefined }[];
};
const calculations = !Number.isNaN(currentElo)
? (games ?? []).reduce<Deltas>(
(acc, game) => {
if (!Number.isNaN(game?.opponentElo) && game?.result) {
const result =
game?.result === "win" ? 1 : game?.result === "loss" ? 0 : 0.5;
const { delta } = getNewRating(
currentElo!,
game.opponentElo!,
result,
parseInt(kFactor!, 10),
);
return {
rating: acc.rating + delta,
deltas: [...acc.deltas, { rating: acc.rating + delta, delta }],
};
}
return {
rating: acc.rating,
deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }],
};
},
{ rating: currentElo!, deltas: [] },
)
: { rating: currentElo!, deltas: [] };
const deltas = calculations.deltas;
const hasDeltas = deltas.some((d) => d.delta !== undefined);
const totalDelta = Math.round(
deltas.reduce((acc, delta) => acc + (delta.delta ?? 0), 0),
);
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
name="currentElo"
control={form.control}
label={t("currentEloLabel")}
placeholder={t("currentEloPlaceholder")}
type="number"
required
/>
<SelectField
name="kFactor"
control={form.control}
label={t("yourKFactorLabel")}
options={["40", "20", "10"].map((k) => ({
value: k,
label: k,
}))}
isClearable={false}
required
/>
</div>
<KFactor className="mt-2" />
<h3 className="my-4 text-lg text-gray-900 dark:text-white">
{t("resultsTitle")}
</h3>
<div className="flex w-full flex-col gap-6 sm:gap-2">
{gameFields.map((game, i) => {
return (
<div key={i} className="flex w-full flex-col">
<div key={i} className="flex w-full items-center space-x-2">
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
name={`games.${i}.opponentElo`}
control={form.control}
placeholder={t("opponentEloPlaceholder")}
type="number"
required
/>
<div className="flex items-center space-x-2">
<RadioGroupField
name={`games.${i}.result`}
control={form.control}
options={["win", "draw", "loss"].map((result) => ({
value: result,
label: t("gameResult", { result }),
}))}
required
/>
{gameFields.length > 1 && (
<button
className="hidden h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-600 dark:hover:bg-neutral-700 sm:flex"
type="button"
onClick={() => removeGame(i)}
>
<IoCloseOutline className="h-6 w-6 text-gray-900 transition-all duration-200 hover:text-primary dark:text-neutral-400 hover:dark:text-white" />
</button>
)}
</div>
</div>
{gameFields.length > 1 && (
<button
className="flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-600 dark:hover:bg-neutral-700 sm:hidden"
type="button"
onClick={() => removeGame(i)}
>
<IoCloseOutline className="h-6 w-6 text-gray-900 transition-all duration-200 hover:text-primary dark:text-neutral-400 hover:dark:text-white" />
</button>
)}
</div>
{deltas[i]?.delta !== undefined && (
<div
className={twMerge(
"mt-2 flex justify-end text-gray-900 dark:text-neutral-400",
gameFields.length > 1 && "mr-10",
i === deltas.length - 1 && "font-bold",
)}
>
<span
className={twMerge(
deltas[i].delta! > 0 && "text-success",
deltas[i].delta! < 0 && "text-error",
)}
>
{deltas[i]!.delta! >= 0 ? "+" : ""}
{deltas[i].delta!}
</span>
</div>
)}
</div>
);
})}
</div>
{hasDeltas && (
<div className="mt-8 text-right text-lg font-bold text-gray-900 dark:text-neutral-400">
{t.rich("finalRating", {
rating: Math.round(last(deltas)!.rating),
delta: () => (
<span
className={twMerge(
totalDelta! > 0 && "text-success",
totalDelta! < 0 && "text-error",
)}
>
{totalDelta >= 0 ? "+" : ""}
{totalDelta}
</span>
),
})}
</div>
)}
<div className="mt-8 flex justify-end">
<button
onClick={() => {
appendGame({});
}}
type="button"
className="rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
<IoAdd className="mr-2 inline-block h-5 w-5" />
{t("addGameButton")}
</button>
</div>
</form>
</FormProvider>
);
};
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { last } from "lodash";
import { useTranslations } from "next-intl";
import { twMerge } from "tailwind-merge";
import { getNewRating } from "@/utils/eloCalculator";
import { TournamentResultsData } from "@/utils/trpc";
type TournamentResultsProps = {
results: TournamentResultsData;
playerId: string;
kFactor: number;
};
export const TournamentResults = ({
results,
playerId,
kFactor,
}: TournamentResultsProps) => {
const t = useTranslations("Elo");
const playerResults = results?.find((p) => p.id === playerId);
if (!playerResults) {
return null;
}
type Deltas = {
rating: number;
deltas: {
rating: number;
delta: number | undefined;
opponentName: string | null;
opponentElo: number | null;
result: number | null;
lostByForfeit: boolean;
wonByForfeit: boolean;
estimated: boolean;
national: boolean;
colour: string;
}[];
};
const currentElo = playerResults.elo;
const calculations = !Number.isNaN(currentElo)
? (playerResults.results ?? []).reduce<Deltas>(
(acc, game) => {
const info = {
opponentName: game.opponent,
opponentElo: game.elo,
lostByForfeit: game.lostByForfeit,
wonByForfeit: game.wonByForfeit,
estimated: game.estimated,
national: game.national,
colour: game.colour,
result: game.result,
};
if (
!game.wonByForfeit &&
!game.lostByForfeit &&
!game.estimated &&
!game.national &&
game.result !== null
) {
const { delta } = getNewRating(
currentElo!,
game.elo!,
game.result as 1 | 0 | 0.5,
kFactor,
);
return {
rating: acc.rating + delta,
deltas: [
...acc.deltas,
{ rating: acc.rating + delta, delta, ...info },
],
};
}
return {
rating: acc.rating,
deltas: [
...acc.deltas,
{ rating: acc.rating, delta: undefined, ...info },
],
};
},
{ rating: currentElo!, deltas: [] },
)
: { rating: currentElo!, deltas: [] };
const deltas = calculations.deltas;
const totalDelta = Math.round(
deltas.reduce((acc, delta) => acc + (delta.delta ?? 0), 0),
);
const noChangeCount = (deltas ?? []).filter(
({ wonByForfeit, lostByForfeit, estimated, national }) =>
wonByForfeit || lostByForfeit || estimated || national,
).length;
if (playerResults.estimated || playerResults.national)
return <div className="my-8 text-center text-error">{t("needRating")}</div>;
return (
<>
<div className="my-8 text-gray-900 dark:text-neutral-400">
{t("initialRating", { rating: Math.round(playerResults.elo) })}
</div>
<table className="mt-8 w-full">
<tbody>
{deltas.map((delta, i) => {
const { estimated, national, lostByForfeit, wonByForfeit } = delta;
const opponentElo = delta.opponentElo
? ` (${delta.opponentElo} ${
estimated ? "E" : national ? "N" : "F"
})`
: "";
const opponentName = delta.opponentName ? (
<span>
<span className="whitespace-nowrap font-bold">
{delta.opponentName}
</span>
{opponentElo}
</span>
) : (
"-"
);
const playedWhite = delta.colour === "white";
const whitePlayer = playedWhite ? playerResults.name : opponentName;
const blackPlayer = !playedWhite
? playerResults.name
: opponentName;
const noChange =
wonByForfeit || lostByForfeit || estimated || national;
const forfeit = delta.opponentName === null;
let result = "";
if (wonByForfeit) {
result = t("wonByForfeit");
} else if (lostByForfeit) {
result = t("lostByForfeit");
} else if (delta.result === 0.5) {
result = t("draw");
} else if (
(playedWhite && delta.result === 1) ||
(!playedWhite && delta.result === 0)
) {
result = t("whiteWin");
} else result = t("blackWin");
return (
<tr key={i} className="text-gray-900 dark:text-neutral-400">
{forfeit ? (
<td className="pb-2">{t("forfeit")}</td>
) : (
<td className="pb-2">
{whitePlayer}
{t("vs")}
{blackPlayer}
</td>
)}
<td className="whitespace-nowrap px-2 pb-2 text-center">
{!forfeit && result}
</td>
<td className="pb-2 text-right">
{noChange ? (
"-"
) : (
<span
className={twMerge(
deltas[i].delta! > 0 && "text-success",
deltas[i].delta! < 0 && "text-error",
)}
>
{deltas[i]!.delta! >= 0 ? "+" : ""}
{deltas[i].delta!}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{noChangeCount > 0 && (
<div className="mt-4 rounded border border-gray-500 p-4 text-center text-gray-500 dark:border-neutral-400 dark:text-neutral-400">
{t("noChangeInfo", { noChangeCount })}
</div>
)}
{deltas.length > 0 && (
<div className="mt-8 text-right text-lg font-bold text-gray-900 dark:text-neutral-400">
{t.rich("finalRating", {
rating: Math.round(last(deltas)!.rating),
delta: () => (
<span
className={twMerge(
totalDelta! > 0 && "text-success",
totalDelta! < 0 && "text-error",
)}
>
{totalDelta >= 0 ? "+" : ""}
{totalDelta}
</span>
),
})}
</div>
)}
</>
);
};
+279
View File
@@ -0,0 +1,279 @@
"use client";
import { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isEmpty, sortBy } from "lodash";
import { useTranslations } from "next-intl";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { Spinner } from "@/components/Spinner";
import { SelectField } from "@/components/form/SelectField";
import { TextField } from "@/components/form/TextField";
import { fetchTournamentResultsSchema } from "@/schemas";
import { Link } from "@/utils/navigation";
import { trpc } from "@/utils/trpc";
import { KFactor } from "./KFactor";
import { ManualEloForm } from "./ManualEloForm";
import { TournamentResults } from "./TournamentResults";
const kFactors = ["40", "20", "10"];
const formSchema = fetchTournamentResultsSchema.extend({
player: z.string().optional(),
kFactor: z.string(),
});
type FetchResultsFormValues = z.infer<typeof formSchema>;
export default function Elo() {
const t = useTranslations("Elo");
type TranslationKey = Parameters<typeof t>[0];
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const current = useMemo(
() => new URLSearchParams(Array.from(searchParams.entries())),
[searchParams],
);
const url = searchParams.get("url") ?? "";
const kFactorParam = searchParams.get("k");
const player = searchParams.get("player") ?? "";
const kFactor =
kFactorParam && kFactors.includes(kFactorParam) ? kFactorParam : "20";
const hasUrl = !isEmpty(url.trim());
const {
data: allResults,
isFetching,
error,
} = trpc.fetchTournamentResults.useQuery(
{
url: url?.trim(),
},
{
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: 10 * 60 * 1000,
enabled: hasUrl,
retry: false,
},
);
const playerOptions = sortBy(
(allResults ?? []).map((player) => ({
value: player.id,
label: player.name,
})),
"label",
);
const form = useForm<FetchResultsFormValues>({
resolver: zodResolver(fetchTournamentResultsSchema),
defaultValues: {
url: url ?? "",
kFactor: kFactor ?? "20",
player: player ?? "",
},
});
const onSubmit = async (input: FetchResultsFormValues) => {
current.set("url", input.url);
if (input.kFactor) current.set("k", input.kFactor);
const search = current.toString();
const query = search ? `?${search}` : "";
// Push the new URL
router.push(`${pathname}${query}`);
};
const clearForm = () => {
current.delete("url");
current.delete("k");
current.delete("player");
form.setValue("url", "");
form.setValue("kFactor", "20");
form.setValue("player", "");
const search = current.toString();
const query = search ? `?${search}` : "";
router.push(`${pathname}${query}`);
};
useEffect(() => {
// We subscribe to form changes and update the URL parameters
const subscription = form.watch((value, { name, type }) => {
let update = false;
if (type === "change") {
if (name === "player" && value.player !== player && value.player) {
current.set("player", value.player);
update = true;
} else if (
name === "kFactor" &&
value.kFactor !== kFactor &&
value.kFactor
) {
current.set("k", value.kFactor);
update = true;
}
if (update) {
const search = current.toString();
const query = search ? `?${search}` : "";
router.replace(`${pathname}${query}`);
}
}
});
return () => subscription.unsubscribe();
}, [current, form, form.watch, kFactor, pathname, player, router]);
useEffect(() => {
// When the URL changes, we update the form values
if (url !== form.getValues("url")) {
form.setValue("url", url);
}
if (player !== form.getValues("player")) {
form.setValue("player", player);
}
if (kFactor !== form.getValues("kFactor")) {
form.setValue("kFactor", form.getValues("kFactor"));
}
}, [searchParams, form, url, player, kFactor]);
if (error) {
console.error(error);
}
const playerResults = allResults?.find((p) => p.id === player);
return (
<section className="grid place-items-center bg-white pb-20 dark:bg-gray-800">
<div className="max-w-xl px-4 py-8 lg:py-16">
<h2
className="mb-10 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
data-test="header2"
>
{t("title")}
</h2>
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400">
{t("info")}
</p>
{hasUrl && !isFetching && !error && (
<div className="mx-auto mb-8 flex justify-center">
<button
type="button"
onClick={clearForm}
className="rounded-lg border border-primary px-3 py-2 text-center text-sm text-primary hover:bg-primary hover:text-white focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:focus:ring-primary-800 sm:w-fit"
>
{t("enterManualResultsButton")}
</button>
</div>
)}
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="flex items-end gap-4">
<TextField
name="url"
control={form.control}
label={t("resultsUrlLabel")}
placeholder={t("resultsUrlLabel")}
/>
<button
disabled={form.formState.isSubmitting}
type="submit"
className="rounded-lg border border-transparent bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 disabled:opacity-25 dark:text-white dark:hover:bg-primary-700 dark:focus:ring-primary-800 sm:w-fit"
>
{t("fetchResultsButton")}
</button>
</div>
{isFetching && (
<div className="mt-8 flex justify-center">
<Spinner />
</div>
)}
{error && (
<div className="mt-8 text-center text-error">
{error.message.startsWith("ERR_")
? t(error.message as TranslationKey)
: t.rich("unknownError", {
contact: (chunks) => (
<Link className="underline" href="/contact-us">
{chunks}
</Link>
),
})}
</div>
)}
{!isFetching && playerOptions.length > 0 && (
<div>
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<SelectField
name="player"
control={form.control}
required
label={t("choosePlayerLabel")}
placeholder={t("choosePlayerPlaceholder")}
options={playerOptions}
isClearable={false}
/>
<SelectField
name="kFactor"
control={form.control}
label={t("kFactorLabel")}
options={["40", "20", "10"].map((k) => ({
value: k,
label: k,
}))}
isClearable={false}
required
/>
</div>
<KFactor className="mt-2" />
</div>
)}
</form>
</FormProvider>
{hasUrl && !isFetching && player && playerResults && !error && (
<TournamentResults
playerId={player}
kFactor={parseInt(kFactor)}
results={allResults ?? []}
/>
)}
{((!hasUrl && !isFetching) || !!error) && (
<>
<div className="relative my-8">
<div className="absolute top-1/2 z-10 h-[1px] w-full bg-gray-500 dark:bg-gray-400" />
<div className="relative z-20 mx-auto w-fit bg-white px-2 py-1 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{t("useManualLabel")}
</div>
</div>
<ManualEloForm />
</>
)}
</div>
</section>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getTranslator } from "next-intl/server";
import { Inter, Julius_Sans_One } from "next/font/google";
import { notFound } from "next/navigation";
import Script from "next/script";
import "@/css/globals.css";
import Providers from "@/providers";
import Footer from "./components/Footer";
import Navbar from "./components/Navbar";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const title = Julius_Sans_One({
subsets: ["latin"],
weight: "400",
variable: "--font-title",
});
export async function generateMetadata({
params: { locale },
}: {
params: { locale?: string };
}) {
// While the `locale` is required, the namespace is optional and
// identical to the parameter that `useTranslations` accepts.
const t = await getTranslator(locale ?? "fr", "Metadata");
return {
title: t("title"),
description: t("description"),
keywords: t("keywords"),
};
}
export default async function RootLayout({
children,
params: { locale },
}: {
children: ReactNode;
params: { locale?: string };
}) {
let messages;
try {
messages = (await import(`@/messages/${locale}.json`)).default;
} catch (error) {
notFound();
}
return (
<html lang={locale} className={`${inter.variable} ${title.variable}`}>
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
/>
<meta name="application-name" content="Echecs France" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Echecs France" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#00A1F0" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
<div className="bg-white font-sans leading-normal tracking-normal dark:bg-gray-800">
<Navbar />
<div className="relative min-h-content">{children}</div>
<Footer />
</div>
</NextIntlClientProvider>
<Script
defer
src="https://app.tinyanalytics.io/pixel/HyoumUokLr9exPgX"
></Script>
</Providers>
</body>
</html>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useTranslations } from "next-intl";
import Image from "next/image";
import bgImage from "@/img/map-bg.jpg";
import { Link } from "@/utils/navigation";
export default function Home() {
const t = useTranslations("Home");
return (
<header className="relative mb-12 flex min-h-content items-center justify-center">
<div className="absolute h-full w-full py-4 brightness-[0.2]">
<Image
src={bgImage}
alt="Background image of France"
fill={true}
style={{ objectFit: "cover" }}
/>
</div>
<div className="z-10 py-4 text-center text-white">
<h1 className="p-5 font-title text-5xl" data-test="header1">
{t("title")}
</h1>
<h2 className="p-5 text-3xl">{t("purpose")}</h2>
<h3 className="mb-5 p-5 text-xl">
{t.rich("how", {
link: (chunks) => (
<a href="http://www.echecs.asso.fr/" target="_blank">
<abbr title="Fédération Française des Échecs">{chunks}</abbr>
</a>
),
})}
</h3>
<Link
href="/tournaments"
className="mb-2 rounded-lg bg-primary px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-primary-700 hover:bg-gradient-to-br focus:outline-none focus:ring-4 focus:ring-primary-300 dark:focus:ring-primary-800"
>
{t("mapLink")}
</Link>
</div>
</header>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useMemo } from "react";
import { useAtomValue } from "jotai";
import L from "leaflet";
import { useTranslations } from "next-intl";
import { useMap } from "react-leaflet";
import { filteredTournamentsByTimeControlAtom } from "@/atoms";
import { TimeControlColours } from "@/constants";
import { TimeControl } from "@/types";
const Legend = () => {
const at = useTranslations("App");
const map = useMap();
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
const timeControls = useMemo(
() =>
[
TimeControl.Classic,
TimeControl.Rapid,
TimeControl.Blitz,
TimeControl.Other,
].filter((tc) => tournaments.some((t) => t.timeControl === tc)),
[tournaments],
);
useEffect(() => {
if (map) {
// @ts-ignore
const legend = L.control({ position: "bottomleft" });
legend.onAdd = () => {
const div = L.DomUtil.create("div", "map-legend");
div.setAttribute(
"style",
"background: white; color: black; border: 2px solid grey; border-radius: 6px; padding: 10px;",
);
div.innerHTML = `
<ul>
${timeControls
.map(
(tc) => `
<li>
<span class="block h-4 w-7 border border-[#999] float-left mr-1" style="background: ${
TimeControlColours[tc]
}"></span>${at("timeControlEnum", { tc })}
</li>
`,
)
.join("")}
</ul>`;
return div;
};
legend.addTo(map);
}
}, [map, at]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
export default Legend;
@@ -0,0 +1,48 @@
import { useAtom, useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import {
blitzAtom,
classicAtom,
otherAtom,
rapidAtom,
tournamentsAtom,
} from "@/atoms";
import { TimeControl } from "@/types";
const TimeControlFilters = () => {
const at = useTranslations("App");
const tournaments = useAtomValue(tournamentsAtom);
const classic = useAtom(classicAtom);
const rapid = useAtom(rapidAtom);
const blitz = useAtom(blitzAtom);
const other = useAtom(otherAtom);
const checkboxes = [
{ tc: TimeControl.Classic, atom: classic },
{ tc: TimeControl.Rapid, atom: rapid },
{ tc: TimeControl.Blitz, atom: blitz },
{ tc: TimeControl.Other, atom: other },
].filter(({ tc }) => tournaments.some((t) => t.timeControl === tc));
return (
<div className="flex flex-wrap gap-3">
{checkboxes.map(({ tc, atom }, i) => (
<div key={i} className="text-gray-900 dark:text-white">
<label>
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
checked={atom[0]}
onChange={() => atom[1](!atom[0])}
/>
{at("timeControlEnum", { tc })}
</label>
</div>
))}
</div>
);
};
export default TimeControlFilters;
@@ -0,0 +1,95 @@
"use client";
import { useCallback, useMemo } from "react";
import { useAtomValue } from "jotai";
import L, { LatLngLiteral } from "leaflet";
import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet.smooth_marker_bouncing";
import "leaflet/dist/leaflet.css";
import { countBy, groupBy } from "lodash";
import { filteredTournamentsByTimeControlAtom, normsOnlyAtom } from "@/atoms";
import { Map, MapMarker } from "@/components/Map";
import { TimeControlColours } from "@/constants";
import { generatePieSVG } from "@/lib/pie";
import { TimeControl } from "@/types";
import Legend from "./Legend";
import TimeControlFilters from "./TimeControlFilters";
import { TournamentMarker } from "./TournamentMarker";
const TournamentMap = () => {
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
const normsOnly = useAtomValue(normsOnlyAtom);
const filteredTournaments = useMemo(
() => tournaments.filter((t) => (normsOnly ? t.norm : true)),
[normsOnly, tournaments],
);
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
const createClusterCustomIcon = useCallback((cluster: any) => {
const childCount = cluster.getChildCount();
const children = cluster.getAllChildMarkers();
// We added the time control as the class name when creating the marker
const timeControlCounts = countBy(
children,
(child: any) => child.options.icon.options.className,
);
const html = `
<div>
${generatePieSVG(
"absolute w-[30px]",
15,
[
TimeControl.Classic,
TimeControl.Rapid,
TimeControl.Blitz,
TimeControl.Other,
].map((tc) => ({
value: timeControlCounts[tc] ?? 0,
colour: TimeControlColours[tc],
})),
)}
<span class="text-white font-semibold relative z-[300]">${childCount}</span>
</div>
`;
return new L.DivIcon({
html,
className: "marker-cluster bg-gray-600/20",
iconSize: new L.Point(40, 40),
});
}, []);
const markers: MapMarker[] = useMemo(
() =>
Object.values(groupedTournaments).map((tournamentGroup) => {
const { groupId } = tournamentGroup[0];
return {
markerId: groupId,
tableIds: tournamentGroup.map((t) => t.id),
component: (
<TournamentMarker key={groupId} tournamentGroup={tournamentGroup} />
),
};
}),
[groupedTournaments],
);
return (
<Map
markers={markers}
legend={<Legend />}
filters={<TimeControlFilters />}
iconCreateFunction={createClusterCustomIcon}
/>
);
};
export default TournamentMap;
@@ -0,0 +1,110 @@
"use client";
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import { useSetAtom } from "jotai";
import L from "leaflet";
import { last } from "lodash";
import { useTranslations } from "next-intl";
import { FaTrophy } from "react-icons/fa";
import { Marker, MarkerProps, Popup } from "react-leaflet";
import { debouncedHoveredMapIdAtom } from "@/atoms";
import type { MarkerRef } from "@/components/Map";
import { TimeControlColours } from "@/constants";
import type { BouncingMarker } from "@/leafletTypes";
import { Tournament } from "@/types";
type TournamentMarkerProps = {
tournamentGroup: Tournament[];
} & Omit<MarkerProps, "position">;
export const TournamentMarker = forwardRef<MarkerRef, TournamentMarkerProps>(
({ tournamentGroup, ...markerProps }, ref) => {
const t = useTranslations("Tournaments");
const markerRef = useRef<L.Marker<any> | null>(null);
useImperativeHandle(
ref,
() => ({
getMarker: () => markerRef.current!,
bounce: () => {
const bouncingMarker = markerRef.current as BouncingMarker;
bouncingMarker.setBouncingOptions({ contractHeight: 0 });
bouncingMarker.bounce();
},
}),
[],
);
const { date, latLng, groupId, timeControl } = tournamentGroup[0];
const setHoveredMapId = useSetAtom(debouncedHoveredMapIdAtom);
const iconOptions = useMemo(
() =>
new L.DivIcon({
html: `
<svg x="0px" y="0px" viewBox="0 0 365 560" enable-background="new 0 0 365 560" xml:space="preserve">
<g><circle fill="#FFFFFF" cx="182" cy="180" r="70" /></g>
<g><path stroke="#666666" stroke-width="10" fill="${TimeControlColours[timeControl]}" d="M182.9,551.7c0,0.1,0.2,0.3,0.2,0.3S358.3,283,358.3,194.6c0-130.1-88.8-186.7-175.4-186.9 C96.3,7.9,7.5,64.5,7.5,194.6c0,88.4,175.3,357.4,175.3,357.4S182.9,551.7,182.9,551.7z M122.2,187.2c0-33.6,27.2-60.8,60.8-60.8 c33.6,0,60.8,27.2,60.8,60.8S216.5,248,182.9,248C149.4,248,122.2,220.8,122.2,187.2z"/></g>
</svg>
`,
className: timeControl,
iconSize: [24, 40],
iconAnchor: [12, 40],
popupAnchor: [1, -40],
}),
[timeControl],
);
const startDate = date;
const endDate = last(tournamentGroup)!.date;
return (
<Marker
ref={markerRef}
position={latLng}
icon={iconOptions}
eventHandlers={{
mouseover: () => setHoveredMapId(groupId),
mouseout: () => setHoveredMapId(null),
}}
{...markerProps}
>
<Popup maxWidth={10000}>
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
<b>
{date}
{endDate !== startDate && ` - ${endDate}`}
</b>
<div className="flex flex-col gap-0">
{tournamentGroup.map((tournament) => (
<a
key={tournament.id}
href={tournament.url}
target="_blank"
rel="noopener noreferrer"
>
{tournament.tournament}
</a>
))}
</div>
{tournamentGroup.some((t) => t.norm) && (
<div className="flex items-center">
<FaTrophy className="mr-3 h-4 w-4" />
{t("norm")}
</div>
)}
{t("approx")}
</div>
</Popup>
</Marker>
);
},
);
TournamentMarker.displayName = "TournamentMarker";
@@ -0,0 +1,176 @@
"use client";
import { useEffect } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useTranslations } from "next-intl";
import { FaExternalLinkAlt } from "react-icons/fa";
import { FaTrophy } from "react-icons/fa";
import { Tooltip } from "react-tooltip";
import { twMerge } from "tailwind-merge";
import {
debouncedHoveredListIdAtom,
debouncedHoveredMapIdAtom,
filteredTournamentsListAtom,
hoveredMapIdAtom,
normsOnlyAtom,
syncVisibleAtom,
} from "@/atoms";
import ScrollToTopButton from "@/components/ScrollToTopButton";
import SearchBar from "@/components/SearchBar";
import { useBreakpoint } from "@/hooks/tailwind";
import TimeControlFilters from "./TimeControlFilters";
const TournamentTable = () => {
const t = useTranslations("Tournaments");
const at = useTranslations("App");
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
const hoveredMapId = useAtomValue(hoveredMapIdAtom);
const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom);
const setHoveredListId = useSetAtom(debouncedHoveredListIdAtom);
const isLg = useBreakpoint("lg");
useEffect(() => {
if (!isLg || debouncedHoveredMapId === null) return;
const tournamentRow = document.querySelector(
`[data-group-id="${debouncedHoveredMapId}"]`,
);
tournamentRow?.scrollIntoView({ behavior: "smooth" });
}, [debouncedHoveredMapId, isLg]);
return (
<section
className="grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-content lg:overflow-y-scroll lg:pb-0"
id="listing"
>
<div className="z-10 flex w-full flex-wrap items-center justify-between gap-3 p-3">
<SearchBar />
<div className="flex flex-col gap-0 text-gray-900 dark:text-white">
<label>
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
checked={syncVisible}
onChange={() => setSyncVisible(!syncVisible)}
/>
{t("syncWithMapCheckbox")}
</label>
<label>
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
checked={normsOnly}
onChange={() => setNormsOnly(!normsOnly)}
/>
{t("normsOnly")}
</label>
</div>
<div className="hidden lg:block">
<TimeControlFilters />
</div>
</div>
<ScrollToTopButton />
<div className="overflow-x-scroll">
<table className="relative min-w-full table-fixed text-center text-xs lg:w-full">
<thead>
<tr>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("date")}
</th>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("town")}
</th>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("tournament")}
</th>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("timeControl")}
</th>
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("ffe")}
</th>
</tr>
</thead>
<tbody>
{filteredTournaments.length === 0 ? (
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
<td colSpan={4} className="p-3">
{t("noneFound")}
</td>
</tr>
) : (
filteredTournaments.map((tournament) => (
<tr
key={tournament.id}
id={tournament.id}
data-group-id={tournament.groupId}
onMouseEnter={() => setHoveredListId(tournament.id)}
onMouseLeave={() => setHoveredListId(null)}
className={twMerge(
"scroll-m-20 bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900",
hoveredMapId === tournament.groupId &&
"bg-gray-200 dark:bg-gray-900",
)}
>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{tournament.date}
</td>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{tournament.town}
</td>
<td className="px-1 py-2 text-left sm:px-3 sm:py-3 lg:px-3 lg:py-3">
<span>
{tournament.norm && (
<FaTrophy
className="mr-2 inline-block h-4 w-4"
data-norm="norm"
/>
)}
{tournament.tournament}
</span>
</td>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{at("timeControlEnum", { tc: tournament.timeControl })}
</td>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
<div className="flex justify-center">
<a
href={tournament.url}
target="_blank"
className="text-primary hover:text-primary-800"
>
<FaExternalLinkAlt />
</a>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Tooltip anchorSelect="[data-norm='norm']">
<div className="flex items-center">
<FaTrophy className="mr-3 h-4 w-4" />
{t("norm")}
</div>
</Tooltip>
</section>
);
};
export default TournamentTable;
@@ -0,0 +1,40 @@
"use client";
import { useHydrateAtoms } from "jotai/utils";
import dynamic from "next/dynamic";
import { tournamentsAtom } from "@/atoms";
import LoadingMap from "@/components/LoadingMap";
import { Tournament } from "@/types";
import TournamentTable from "./TournamentTable";
type TournamentsDisplayProps = {
tournaments: Tournament[];
};
/**
* Imports the tournament map component, ensuring CSR only.
* @remarks SSR is not supported by react-leaflet
*/
const TournamentMap = dynamic(() => import("./TournamentMap"), {
ssr: false,
loading: LoadingMap,
});
export default function TournamentsDisplay({
tournaments,
}: TournamentsDisplayProps) {
useHydrateAtoms([[tournamentsAtom, tournaments]]);
return (
<main className="relative grid h-full w-full lg:grid-cols-2">
<div>
<TournamentMap />
</div>
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
<TournamentTable />
</div>
</main>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { differenceInDays, isSameDay, parse } from "date-fns";
import { groupBy } from "lodash";
import clientPromise from "@/lib/mongodb";
import { TournamentData } from "@/types";
import { TimeControl, Tournament } from "@/types";
import { errorLog } from "@/utils/logger";
import TournamentsDisplay from "./TournamentsDisplay";
export const revalidate = 3600; // Revalidate cache every 6 hours
const tcMap: Record<TournamentData["time_control"], TimeControl> = {
"Cadence Lente": TimeControl.Classic,
Rapide: TimeControl.Rapid,
Blitz: TimeControl.Blitz,
};
const getTournaments = async () => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
const data = await db
.collection("tournaments")
.aggregate<TournamentData>([
{
$addFields: {
dateParts: {
$dateFromString: {
dateString: "$date",
format: "%d/%m/%Y",
},
},
},
},
{
$sort: {
dateParts: 1,
},
},
{
$unset: "dateParts",
},
])
.toArray();
// Group the tournaments by their location
const groupedByLocation = groupBy(
data,
(t) => `${t.coordinates[0]}_${t.coordinates[1]}`,
);
// For each location, create an array of arrays of contiguous dates for this location
const dateRangesByLocation: Record<string, Date[][]> = {};
for (const location in groupedByLocation) {
const tournaments = groupedByLocation[location];
// Note that this works since the tournaments are sorted by date
const dateRanges = tournaments.reduce<Date[][]>(
(acc, tournament) => {
const group = acc[acc.length - 1];
const date = parse(tournament.date, "dd/MM/yyyy", new Date());
const diff = differenceInDays(date, group[group.length - 1] ?? date);
if (diff > 1) {
acc.push([date]);
} else if (group.length === 0 || diff === 1) {
group.push(date);
}
return acc;
},
[[]],
);
dateRangesByLocation[location] = dateRanges;
}
return data.map<Tournament>((t) => {
const location = `${t.coordinates[0]}_${t.coordinates[1]}`;
const date = parse(t.date, "dd/MM/yyyy", new Date());
const dateRanges = dateRangesByLocation[location];
const rangeIndex = dateRanges.findIndex((ranges) =>
ranges.some((d) => isSameDay(d, date)),
);
// We place each tournament into a group based on location and date and time control, so that
// we can display a single map marker.
const timeControl = tcMap[t.time_control] ?? TimeControl.Other;
const groupId = `${location}_${rangeIndex}_${timeControl}`;
return {
id: t._id.toString(),
groupId,
tournament: t.tournament,
town: t.town,
department: t.department,
date: t.date,
url: t.url,
timeControl,
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
norm: t.norm_tournament,
pending: t.pending,
status: t.status,
};
});
} catch (error) {
errorLog(error);
throw new Error("Error fetching tournament data");
}
};
export default async function Tournaments() {
const tournaments = await getTournaments();
return <TournamentsDisplay tournaments={tournaments} />;
}
+21
View File
@@ -0,0 +1,21 @@
import {
FetchCreateContextFnOptions,
fetchRequestHandler,
} from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/appRouter";
const handler = (request: Request) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: appRouter,
createContext: function (
opts: FetchCreateContextFnOptions,
): object | Promise<object> {
return {};
},
});
};
export { handler as GET, handler as POST };
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+12
View File
@@ -0,0 +1,12 @@
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/api/",
},
sitemap: "https://echecsfrance/sitemap.xml",
};
}
+28
View File
@@ -0,0 +1,28 @@
import { MetadataRoute } from "next";
import { locales, pathnames } from "@/utils/navigation";
export default function sitemap(): MetadataRoute.Sitemap {
return locales.flatMap((locale) => {
const prefix = `https://echecsfrance.com${
locale === "fr" ? "" : `/${locale}`
}`;
const paths = Object.keys(pathnames) as Array<keyof typeof pathnames>;
return paths.map((pathname) => {
const route = pathnames[pathname];
if (typeof route === "string") {
return {
url: `${prefix}${route}`,
lastModified: new Date(),
};
}
return {
url: `${prefix}${route[locale]}`,
lastModified: new Date(),
};
});
});
}
+104
View File
@@ -0,0 +1,104 @@
import { atom } from "jotai";
import { LatLngBounds, LatLngLiteral } from "leaflet";
import { Club, TimeControl, Tournament } from "@/types";
import atomWithDebounce from "@/utils/atomWithDebounce";
import { normalizedContains } from "@/utils/string";
export const burgerMenuIsOpenAtom = atom(false);
export const mapBoundsAtom = atom<LatLngBounds | null>(null);
export const syncVisibleAtom = atom(true);
export const searchStringAtom = atom("");
export const tournamentsAtom = atom<Tournament[]>([]);
export const normsOnlyAtom = atom(false);
export const classicAtom = atom(true);
export const rapidAtom = atom(true);
export const blitzAtom = atom(true);
export const otherAtom = atom(true);
export const clubsAtom = atom<Club[]>([]);
export const franceCenterAtom = atom<LatLngLiteral>({
lat: 47.0844,
lng: 2.3964,
});
export const {
currentValueAtom: hoveredMapIdAtom,
debouncedValueAtom: debouncedHoveredMapIdAtom,
} = atomWithDebounce<string | null>(null);
export const { debouncedValueAtom: debouncedHoveredListIdAtom } =
atomWithDebounce<string | null>(null, 1000, 100);
export const filteredTournamentsByTimeControlAtom = atom((get) => {
const tournaments = get(tournamentsAtom);
const classic = get(classicAtom);
const rapid = get(rapidAtom);
const blitz = get(blitzAtom);
const other = get(otherAtom);
return tournaments.filter(
(tournament) =>
!tournament.pending &&
tournament.status === "scheduled" &&
((tournament.timeControl === TimeControl.Classic && classic) ||
(tournament.timeControl === TimeControl.Rapid && rapid) ||
(tournament.timeControl === TimeControl.Blitz && blitz) ||
(tournament.timeControl === TimeControl.Other && other)),
);
});
export const filteredTournamentsListAtom = atom((get) => {
const tournaments = get(filteredTournamentsByTimeControlAtom);
const mapBounds = get(mapBoundsAtom);
const syncVisible = get(syncVisibleAtom);
const normsOnly = get(normsOnlyAtom);
const searchString = get(searchStringAtom).trim();
const filteredByNorm = normsOnly
? tournaments.filter((t) => t.norm)
: tournaments;
// When searching, we search all the tournament, regardless of the map display
if (searchString !== "") {
return filteredByNorm.filter(
(t) =>
normalizedContains(t.town, searchString) ||
normalizedContains(t.tournament, searchString),
);
}
// If we not syncing to the map, return all tournaments
if (mapBounds === null || !syncVisible) return filteredByNorm;
// Filter by those in the current map bounds
return filteredByNorm.filter((tournament) =>
mapBounds.contains(tournament.latLng),
);
});
export const filteredClubsListAtom = atom((get) => {
const clubs = get(clubsAtom);
const mapBounds = get(mapBoundsAtom);
const syncVisible = get(syncVisibleAtom);
const searchString = get(searchStringAtom).trim();
// When searching, we search all the tournament, regardless of the map display
if (searchString !== "") {
return clubs.filter(
(club) =>
normalizedContains(club.name, searchString) ||
(club.address && normalizedContains(club.address, searchString)),
);
}
// If we not syncing to the map, return all clubs
if (mapBounds === null || !syncVisible) return clubs;
// Filter by those in the current map bounds
return clubs.filter((club) => mapBounds.contains(club.latLng));
});
+31
View File
@@ -0,0 +1,31 @@
import { Dispatch, SetStateAction } from "react";
import { ResponseMessage } from "@/types";
const InfoMessage = ({
responseMessage,
}: {
responseMessage: ResponseMessage;
}) => (
<p
className={`${
responseMessage.isSuccessful ? "text-green-600" : "text-red-600"
} italic`}
data-test="info-message"
>
{responseMessage.message}
</p>
);
export const clearMessage = (
setResponseMessage: Dispatch<SetStateAction<ResponseMessage>>,
) => {
setTimeout(() => {
setResponseMessage({
isSuccessful: false,
message: "",
});
}, 10000);
};
export default InfoMessage;
+12
View File
@@ -0,0 +1,12 @@
import { useTranslations } from "next-intl";
const LoadingMap = () => {
const t = useTranslations("Tournaments");
return (
<div className="grid h-content place-self-center bg-white text-center text-gray-900 dark:bg-gray-800 dark:text-white">
<p>{t("loading")}</p>
</div>
);
};
export default LoadingMap;
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import React from "react";
import { useAtomValue, useSetAtom } from "jotai";
import L, {
DomUtil,
LatLngLiteral,
Marker,
MarkerClusterGroupOptions,
} from "leaflet";
import "leaflet-defaulticon-compatibility";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet.smooth_marker_bouncing";
import "leaflet/dist/leaflet.css";
import { FaAngleDoubleDown } from "react-icons/fa";
import { LayerGroup, MapContainer, TileLayer } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import { debouncedHoveredListIdAtom, mapBoundsAtom } from "@/atoms";
import MapEvents from "@/components/MapEvents";
export type MarkerRef = {
getMarker: () => L.Marker<any>;
bounce: () => void;
};
// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing
// to keep Typescript happy
declare class BouncingMarker extends Marker {
isBouncing(): boolean;
bounce(): void;
stopBouncing(): void;
_icon: HTMLElement;
_bouncingMotion?: {
bouncingAnimationPlaying: boolean;
};
}
const stopBouncingMarkers = () => {
const markers =
// @ts-ignore
Marker.prototype._orchestration.getBouncingMarkers() as BouncingMarker[];
markers.forEach((marker) => {
if (marker.isBouncing()) {
try {
marker.stopBouncing();
// The plugin keeps bouncing until the end of the animation. We want to stop it immediately
// An issue has been raised on the project (https://github.com/hosuaby/Leaflet.SmoothMarkerBouncing/issues/52), until then we have this hack.
// We remove the class and reset some internal state
DomUtil.removeClass(marker._icon, "bouncing");
if (marker?._bouncingMotion?.bouncingAnimationPlaying === true)
marker._bouncingMotion.bouncingAnimationPlaying = false;
} catch {}
}
});
};
export type MapMarker = {
markerId: string;
tableIds: string[];
component: React.ReactElement;
};
type MapProps = {
filters?: React.ReactNode;
legend?: React.ReactNode;
markers: MapMarker[];
iconCreateFunction?: MarkerClusterGroupOptions["iconCreateFunction"];
};
export const Map = ({
filters,
legend,
markers: markers,
iconCreateFunction,
}: MapProps) => {
const setMapBounds = useSetAtom(mapBoundsAtom);
const hoveredListTournamentId = useAtomValue(debouncedHoveredListIdAtom);
const markerRefs = useRef<Record<string, MarkerRef>>({});
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const onScrollToTable = () => {
const tournamentTable = document.getElementById("listing");
tournamentTable?.scrollIntoView({ behavior: "smooth" });
};
const expandAndBounceIfNeeded = useCallback(() => {
if (hoveredListTournamentId) {
const marker = markers.find((m) =>
m.tableIds.includes(hoveredListTournamentId),
);
if (marker) {
const markerRef = markerRefs.current[marker.markerId];
if (markerRef) {
if (clusterRef.current) {
const visibleMarker = clusterRef.current.getVisibleParent(
markerRef.getMarker(),
);
if (!visibleMarker) return;
// @ts-ignore
if (visibleMarker.__proto__ === L.MarkerCluster.prototype) {
// This is a cluster icon, we expand it.
const clusterMarker = visibleMarker as L.MarkerCluster;
if (
expandedClusterMarkerRef.current &&
expandedClusterMarkerRef.current !== clusterMarker
) {
expandedClusterMarkerRef.current.unspiderfy();
}
clusterMarker.spiderfy();
// Sometimes there marker that's still bouncing from the last time the group was expanded.
// We stop it quickly.
setTimeout(() => {
stopBouncingMarkers();
}, 50);
} else {
// This is a standard marker, we bounce it.
const marker = visibleMarker as BouncingMarker;
if (!marker.isBouncing()) {
stopBouncingMarkers();
markerRef.bounce();
}
}
return true;
}
}
}
}
stopBouncingMarkers();
return false;
}, [markers, hoveredListTournamentId]);
const onSpiderified = useCallback(
(e: L.MarkerClusterSpiderfyEvent) => {
// Once expanded, bounce the appropriate marker
if (hoveredListTournamentId) {
const marker = markers.find((m) =>
m.tableIds.includes(hoveredListTournamentId),
);
if (!marker) return;
expandedClusterMarkerRef.current = e.cluster;
const markerRef = markerRefs.current[marker.markerId];
if (markerRef && e.markers.includes(markerRef.getMarker())) {
stopBouncingMarkers();
markerRef.bounce();
}
}
},
[markers, hoveredListTournamentId],
);
const onUnSpiderified = useCallback(
(e: L.MarkerClusterSpiderfyEvent) => {
if (expandedClusterMarkerRef.current === e.cluster)
expandedClusterMarkerRef.current = null;
// Once closed, we can expand the next group if needed
expandAndBounceIfNeeded();
},
[expandAndBounceIfNeeded],
);
useEffect(() => {
const ref = clusterRef.current;
if (clusterRef.current) {
clusterRef.current.on("spiderfied", onSpiderified);
clusterRef.current.on("unspiderfied", onUnSpiderified);
}
return () => {
if (ref) {
ref.off("spiderfied", onSpiderified);
ref.off("unspiderfied", onUnSpiderified);
}
};
}, [onSpiderified, onUnSpiderified]);
useEffect(() => {
// Expand/contract as hoveredListTournamentId changes
if (expandAndBounceIfNeeded()) return;
if (expandedClusterMarkerRef.current)
expandedClusterMarkerRef.current.unspiderfy();
}, [expandAndBounceIfNeeded, hoveredListTournamentId]);
const referencedMarkers = useMemo(
() =>
markers.map((marker) =>
React.cloneElement(marker.component, {
ref: (ref: MarkerRef) => {
markerRefs.current[marker.markerId] = ref;
},
}),
),
[markers],
);
return (
<section id="tournament-map" className="flex h-content flex-col">
{filters && <div className="p-3 lg:hidden">{filters}</div>}
<MapContainer
center={center}
zoom={5}
scrollWheelZoom={false}
style={{
flexGrow: 1,
}}
ref={(map) => {
if (map) {
setMapBounds(map.getBounds());
}
}}
>
<MapEvents />
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{legend}
<LayerGroup>
<MarkerClusterGroup
ref={clusterRef}
chunkedLoading
iconCreateFunction={iconCreateFunction}
maxClusterRadius={40}
showCoverageOnHover={false}
spiderfyOnMaxZoom
>
{referencedMarkers}
</MarkerClusterGroup>
</LayerGroup>
</MapContainer>
<div className="flex items-center justify-center lg:hidden">
<button
className="p-3 text-primary-900 dark:text-white"
onClick={onScrollToTable}
>
<FaAngleDoubleDown />
</button>
</div>
</section>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useTransition } from "react";
import { useSetAtom } from "jotai";
import L from "leaflet";
import { useMapEvent } from "react-leaflet";
import { mapBoundsAtom } from "@/atoms";
const MapEvents = () => {
const setMapBounds = useSetAtom(mapBoundsAtom);
const [isPending, startTransition] = useTransition();
const worldBounds = L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180));
const franceBounds = L.latLngBounds(
L.latLng(42.08, -5.12),
L.latLng(51.17, 9.53),
);
const map = useMapEvent("moveend", () => {
// Set the map bounds atoms when the user pans/zooms
startTransition(() => {
setMapBounds(map.getBounds());
});
});
// Viewport agnostic centering of France & max world bounds
useEffect(() => {
map.setView(franceBounds.getCenter(), map.getBoundsZoom(franceBounds));
map.setMaxBounds(worldBounds);
map.options.maxBoundsViscosity = 1.0; // Prevents going past bounds while dragging
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
export default MapEvents;
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FaArrowUp } from "react-icons/fa";
import { useBreakpoint } from "@/hooks/tailwind";
import { ScrollableElement } from "@/types";
import { handleScrollToTop } from "@/utils/scrollHandlers";
const ScrollToTopButton = () => {
const scrollToTopElementRef = useRef<ScrollableElement | null>(null);
const isLgScreen = useBreakpoint("lg");
// determine scrollable element based on screen size - window or div
useEffect(() => {
isLgScreen
? (scrollToTopElementRef.current = document.getElementById("listing"))
: (scrollToTopElementRef.current = window);
}, [isLgScreen]);
const scrollToTopButtonClass = isLgScreen
? "absolute bottom-0 right-3 p-10"
: "fixed bottom-20 right-3 py-10";
return (
<button
className={`${scrollToTopButtonClass} z-10 text-2xl text-primary-900 dark:text-white`}
data-test="scroll-to-top-button"
>
<FaArrowUp
onClick={() => handleScrollToTop(scrollToTopElementRef.current)}
/>
</button>
);
};
export default ScrollToTopButton;
+62
View File
@@ -0,0 +1,62 @@
import { useTransition } from "react";
import { useAtom } from "jotai/index";
import { useTranslations } from "next-intl";
import { IoCloseOutline } from "react-icons/io5";
import { searchStringAtom } from "@/atoms";
const SearchBar = () => {
const t = useTranslations("Tournaments");
const [searchString, setSearchString] = useAtom(searchStringAtom);
const [isPending, startTransition] = useTransition();
const updateSearchString = (str: string) => {
startTransition(() => {
setSearchString(str);
});
};
return (
<div className="bg-white dark:bg-gray-800">
<label htmlFor="table-search" className="sr-only">
{t("searchLabel")}
</label>
<div className="relative mt-1">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
></path>
</svg>
</div>
{searchString !== "" && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-900 dark:text-white">
<button onClick={() => setSearchString("")}>
<IoCloseOutline className="h-5 w-5" />
</button>
</div>
)}
<input
type="search"
id="table-search"
className="block rounded-lg border border-gray-300 bg-gray-50 p-2.5 px-10 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
placeholder={t("searchPlaceholder")}
value={searchString}
onChange={(e) => updateSearchString(e.target.value)}
/>
</div>
</div>
);
};
export default SearchBar;
+22
View File
@@ -0,0 +1,22 @@
export const Spinner = () => {
return (
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-8 w-8 animate-spin fill-primary text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
};
@@ -0,0 +1,53 @@
import React from "react";
import { format } from "date-fns";
import { enGB, fr } from "date-fns/locale";
import { useLocale } from "next-intl";
import { ReactDatePickerCustomHeaderProps } from "react-datepicker";
import { IoChevronBack, IoChevronForward } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
const chevronClasses = {
chevronRoot: "h-4 w-4 dark:text-white",
chevronClassDisabled: "!text-neutral-500",
};
export const DatePickerCustomHeader = ({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}: ReactDatePickerCustomHeaderProps) => {
const locale = useLocale();
return (
<div className="mb-2 flex !w-full items-center justify-between border-b border-neutral-500 px-6 pb-4">
<button
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
className={chevronClasses.chevronRoot}
>
<IoChevronBack
className={twMerge(
chevronClasses.chevronRoot,
prevMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
{format(date, "LLLL yyyy", { locale: locale === "fr" ? fr : enGB })}
<button
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className={chevronClasses.chevronRoot}
>
<IoChevronForward
className={twMerge(
chevronClasses.chevronRoot,
nextMonthButtonDisabled && chevronClasses.chevronClassDisabled,
)}
/>
</button>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { forwardRef } from "react";
import InputMask from "react-input-mask";
import { twMerge } from "tailwind-merge";
interface InputProps {
error?: boolean;
className?: string;
inputContainerClass?: string;
inputClass?: string;
mask?: string | (string | RegExp)[];
}
type AllProps = Omit<
React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>,
"ref"
> &
InputProps;
export const InputDatePicker = forwardRef<HTMLInputElement, AllProps>(
(
{
error,
className,
children,
inputContainerClass,
inputClass,
mask,
...props
},
inputRef,
) => {
return (
<div
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 ",
!error &&
"focus-within:border-primary-500 focus-within:ring-primary-500 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500",
error && "!border-orange-700 focus:!border-orange-700",
inputContainerClass,
)}
>
<InputMask
inputRef={inputRef}
mask={mask ?? ""}
className={twMerge(
"w-full border-none bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0",
"text-gray-900 dark:text-white",
inputClass,
)}
onKeyDown={(e) => {
if (e.code === "Enter") {
e.stopPropagation();
}
}}
{...props}
/>
</div>
);
},
);
InputDatePicker.displayName = "InputDatePicker";
@@ -0,0 +1 @@
export * from "./InputDatePicker";
+145
View File
@@ -0,0 +1,145 @@
import React, { useRef } from "react";
import { getYear, isValid, parse } from "date-fns";
import fr from "date-fns/locale/fr";
import { get } from "lodash";
import { useLocale, useTranslations } from "next-intl";
import DatePicker, { registerLocale } from "react-datepicker";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "../Field";
import { InputDatePicker } from "./components";
import { DatePickerCustomHeader } from "./components/DatePickerCustomHeader";
registerLocale("fr", fr);
type DateFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
maxDate?: Date;
minDate?: Date;
dateFormat?: string;
className?: string;
datePickerPopperClass?: string;
required?: boolean;
}
>;
export const DateField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
minDate,
maxDate,
dateFormat = "dd/MM/yyyy",
className,
datePickerPopperClass,
name,
control,
required,
...otherFieldProps
}: DateFieldProps<TFieldValues, TFieldName>) => {
const locale = useLocale();
const at = useTranslations("App");
const min = minDate ? minDate.getFullYear() : 1900;
const inputRef = useRef(null);
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = name && !!get(errors, name)?.message;
return (
<Field name={name} control={control} {...otherFieldProps}>
<div className={twMerge("relative flex w-full flex-col", className)}>
<Controller
control={control}
name={name}
render={({ field }) => (
<DatePicker
locale={locale}
closeOnScroll={true}
placeholderText={at("datePlaceholder")}
portalId="calendar-portal"
dateFormat={dateFormat}
disabledKeyboardNavigation={true}
formatWeekDay={(day: string) => (
<div className="flex h-8 flex-col items-center justify-center">
{day.slice(0, 3)}
</div>
)}
weekDayClassName={() =>
"text-xs h-8 w-8 flex-1 bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white"
}
dayClassName={() =>
"text-xs h-8 w-8 flex-1 bg-gray-50 cursor-pointer hover:font-bold text-gray-900 dark:bg-gray-700 dark:text-white"
}
value={field.value}
selected={field.value}
onSelect={field.onChange}
onChange={field.onChange}
onChangeRaw={(e) => {
// Called when the user types in the input
if (e.currentTarget.value) {
const value = e.currentTarget.value;
const date = parse(value, at("dateParseFormat"), new Date());
if (
isValid(date) &&
date.getFullYear() >= min &&
maxDate &&
getYear(maxDate) >= getYear(date)
) {
field.onChange(date);
}
}
}}
popperPlacement="bottom-start"
popperClassName={twMerge(
"z-50 mt-[12px] rounded border",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
datePickerPopperClass,
)}
showPopperArrow={false}
calendarClassName={twMerge(
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
)}
minDate={minDate}
showFullMonthYearPicker
maxDate={maxDate}
renderCustomHeader={(props) => (
<DatePickerCustomHeader {...props} />
)}
customInput={
<InputDatePicker
mask={at("dateMask")}
inputClass="p-0"
className="text-left"
ref={inputRef}
error={!!hasError}
/>
}
/>
)}
/>
</div>
</Field>
);
};
+21
View File
@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
type ErrorMessageProps = {
errorMessage: string;
};
export const ErrorMessage = ({ errorMessage }: ErrorMessageProps) => {
const t = useTranslations();
type TranslationKey = Parameters<typeof t>[0];
return errorMessage !== undefined ? (
<div className="mt-2 font-medium text-orange-700">
<p>
{errorMessage.startsWith("FormValidation")
? t(errorMessage as TranslationKey)
: errorMessage}
</p>
</div>
) : null;
};
+88
View File
@@ -0,0 +1,88 @@
import { ReactNode } from "react";
import { get } from "lodash";
import {
Control,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { ErrorMessage } from "./ErrorMessage";
import { Label } from "./Label";
export type GenericFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TFieldName;
control: Control<TFieldValues>;
className?: string;
labelClassName?: string;
childrenWrapperClassName?: string;
label?: React.ReactNode;
hideErrorMessage?: boolean;
};
type FieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
children: ReactNode;
}
>;
export const Field = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: FieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
className,
labelClassName,
childrenWrapperClassName,
label,
children,
required,
hideErrorMessage = false,
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = name && !!get(errors, name)?.message;
return (
<div className={twMerge("flex w-full flex-col items-start", className)}>
{label ? (
<Label htmlFor={name} required={required} className={labelClassName}>
{label}
</Label>
) : null}
<div
className={twMerge(
"flex w-full",
label && "mt-2",
childrenWrapperClassName,
)}
>
{children}
</div>
{hideErrorMessage || !hasError ? null : (
<ErrorMessage errorMessage={String(get(errors, name)?.message)} />
)}
</div>
);
};
+35
View File
@@ -0,0 +1,35 @@
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
type LabelProps = Prettify<
React.DetailedHTMLProps<
React.LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
> & {
required?: boolean;
className?: string;
tooltip?: React.ReactNode;
}
>;
export const Label = ({
required,
className,
children,
tooltip,
...labelProps
}: LabelProps) => {
return (
<label
{...labelProps}
className={twMerge(
"flex items-center text-sm font-medium text-gray-900 dark:text-gray-300",
required && "after:ml-0.5 after:content-['*']",
className,
)}
>
<div>{children}</div>
</label>
);
};
+113
View File
@@ -0,0 +1,113 @@
import React from "react";
import { RadioGroup } from "@headlessui/react";
import { isNil } from "lodash";
import { useTranslations } from "next-intl";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
label: string | JSX.Element;
disabled?: boolean;
data?: D;
};
export type RadioGroupFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
options: BaseOption<T, D>[];
}
>;
export const RadioGroupField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T extends React.Key = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
label,
hideErrorMessage,
required,
options,
}: RadioGroupFieldProps<TFieldValues, TFieldName, T, D>) => {
const t = useTranslations("App");
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const valueToOption = (value: T): BaseOption<T, D> =>
options.find((o) => o.value === value) ?? { value, label: "" };
return (
<Field
{...{
name,
control,
className,
label,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
required,
}}
>
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => {
const optionValue = isNil(value) ? null : valueToOption(value);
return (
<RadioGroup
value={value}
onChange={onChange}
className="flex w-full justify-stretch gap-2"
>
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ checked }) =>
twMerge(
"flex flex-1 cursor-pointer items-center justify-center rounded-lg border px-5 py-3 text-center text-sm font-medium focus:outline-none",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
checked &&
"border-primary text-primary dark:border-primary dark:text-primary",
)
}
disabled={option.disabled}
>
<RadioGroup.Label as="span">{option.label}</RadioGroup.Label>
</RadioGroup.Option>
))}
</RadioGroup>
);
}}
/>
</Field>
);
};
+187
View File
@@ -0,0 +1,187 @@
import React from "react";
import { flatMap, get, isArray, isNil, isObject } from "lodash";
import { useTranslations } from "next-intl";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import Select, {
ClassNamesConfig,
GroupBase,
OnChangeValue,
Props,
} from "react-select";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
label: string | JSX.Element;
disabled?: boolean;
data?: D;
};
export const classNames = <Option, IsMulti extends boolean = false>(
hasError: boolean,
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
container: () => "w-full",
valueContainer: () => "text-gray-900 dark:text-white",
indicatorsContainer: () => "flex items-center self-stretch shrink-0",
clearIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
dropdownIndicator: () =>
"pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white",
indicatorSeparator: () => "w-px text-gray-900 dark:text-white",
control: (state) =>
twMerge(
"group flex w-full items-center justify-between rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus-within:border-primary-500 focus-within:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500",
hasError && "ring-1 ring-error",
state.isDisabled && "cursor-not-allowed",
state.isFocused && "border-primary ring ring-primary ring-opacity-50",
),
multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1",
multiValueLabel: () => "",
multiValueRemove: () => "items-center px-1 hover:text-primary",
placeholder: () =>
"block truncate pr-2 placeholder-gray dark:placeholder-gray-400",
menu: () =>
twMerge(
"!z-10 mt-2 rounded-lg border border-gray-200 bg-white p-1",
"border-gray-300 bg-gray-50 text-gray-900",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
),
groupHeading: () => "ml-3 mt-2 mb-1 text-textGray text-sm uppercase",
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
isDisabled && "opacity-50",
isFocused && "hover:bg-primary-500 hover:text-white",
),
noOptionsMessage: () =>
"text-textGray p-2 bg-gray-50 border border-dashed border-gray-200 rounded-sm",
});
export type SelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
"onChange" | "value" | "classNames" | "name"
> & {
required?: boolean;
}
>;
export const SelectField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
label,
hideErrorMessage,
required,
placeholder,
options,
...selectProps
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
const t = useTranslations("App");
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const isGroup = (
option: BaseOption<T, D> | GroupBase<BaseOption<T, D>>,
): option is GroupBase<BaseOption<T, D>> =>
isObject(option) && "options" in option;
const flattenedOptions = flatMap(options, (option) => {
return isGroup(option) ? option.options : option;
});
const valueToOption = (value: T): BaseOption<T, D> =>
flattenedOptions.find((o) => o.value === value) ?? { value, label: "" };
return (
<Field
{...{
name,
control,
className,
label,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
required,
}}
>
<Controller
name={name}
control={control}
render={({ field: { onChange, value } }) => {
const onSelectChange = (
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
) => {
if (isNil(newValue)) onChange(null);
else if (isArray(newValue)) {
onChange(newValue.map((option) => option.value));
} else onChange((newValue as BaseOption<T, D>).value);
};
const optionValue = isNil(value)
? null
: isArray(value)
? // @ts-ignore - this is too complex for TS to understand
value.map(valueToOption)
: valueToOption(value);
return (
<Select
value={optionValue}
onChange={onSelectChange}
options={options}
noOptionsMessage={() => t("noOptionsMessage")}
placeholder={placeholder ?? t("selectPlaceholder")}
unstyled
styles={{
input: (base) => ({
...base,
"input:focus": {
boxShadow: "none",
},
}),
}}
classNames={classNames<BaseOption<T, D>, IsMulti>(hasError)}
{...selectProps}
/>
);
}}
/>
</Field>
);
};
+82
View File
@@ -0,0 +1,82 @@
import React from "react";
import { Switch, SwitchProps } from "@headlessui/react";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type SwitchFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<SwitchProps<"button">, "name">
>;
export const SwitchField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: SwitchFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
disabled,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
...rest
} = props;
const form = useFormContext<TFieldValues>();
return (
<Field
{...{
name,
control,
type: "checkbox",
label,
disabled,
className,
labelClassName,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="flex h-[40px] items-center">
<Switch
checked={field.value}
onChange={field.onChange}
className={twMerge(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none",
"ui-not-checked:bg-neutral-300 ui-not-checked:hover:bg-neutral-400",
"ui-not-checked:dark:bg-neutral-500 ui-not-checked:dark:hover:bg-neutral-600",
"ui-checked:bg-primary-500 ui-checked:hover:bg-primary-600",
)}
{...rest}
>
<span className="inline-block h-[14px] w-[14px] transform rounded-full bg-white transition-transform ui-checked:translate-x-[19px] ui-not-checked:translate-x-[3px]" />
</Switch>
</div>
)}
/>
</div>
</Field>
);
};
+107
View File
@@ -0,0 +1,107 @@
import React from "react";
import { get } from "lodash";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type TextAreaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLTextAreaElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
required?: boolean;
}
>;
export const TextAreaField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextAreaFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage = false,
startIcon,
handleChanged,
required,
disabled,
...rest
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
return (
<Field
{...{
name,
control,
label,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="relative">
{startIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{startIcon}
</div>
)}
<textarea
{...form.register(name, {
required,
})}
aria-required={required}
aria-invalid={hasError}
disabled={disabled}
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500",
hasError && "!border-orange-700 focus:!border-orange-700",
disabled && "dark:bg-gray-50",
)}
{...rest}
onChange={(e) => {
field.onChange(e);
}}
value={field.value}
/>
</div>
)}
/>
</div>
</Field>
);
};
+130
View File
@@ -0,0 +1,130 @@
import React from "react";
import { get } from "lodash";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
type TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLInputElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
}
>;
export const TextField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
type = "text",
label,
required,
disabled,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage = false,
startIcon,
handleChanged,
...rest
} = props;
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const input = (value: string | number) => {
return typeof value === "number"
? isNaN(value)
? ""
: value.toString()
: value ?? "";
};
const output =
type === "number"
? (e: React.ChangeEvent<HTMLInputElement>) => {
const output = parseFloat(e.target.value);
return isNaN(output) ? 0 : output;
}
: (e: React.ChangeEvent<HTMLInputElement>) => e.target.value;
return (
<Field
{...{
name,
control,
label,
required,
className,
labelClassName,
childrenWrapperClassName,
hideErrorMessage,
}}
>
<div className="flex w-full flex-col">
<Controller
control={control}
name={name}
render={({ field }) => (
<div className="relative">
{startIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{startIcon}
</div>
)}
<input
type={type}
{...form.register(name, {
valueAsNumber: type === "number",
required,
})}
aria-required={required}
aria-invalid={hasError}
disabled={disabled}
className={twMerge(
"flex w-full content-center rounded-lg border p-3 text-sm",
"border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus:border-primary-500 focus:ring-primary-500",
"dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500",
hasError && "!border-orange-700 focus:!border-orange-700",
disabled && "dark:bg-gray-50",
startIcon && "pl-10",
)}
{...rest}
onChange={(e) => {
field.onChange(output(e));
}}
value={input(field.value)}
onBlur={() => {
if (handleChanged) handleChanged({ name });
}}
/>
</div>
)}
/>
</div>
</Field>
);
};
+8
View File
@@ -0,0 +1,8 @@
import { TimeControl } from "@/types";
export const TimeControlColours = {
[TimeControl.Classic]: "#00ac39",
[TimeControl.Rapid]: "#0086c7",
[TimeControl.Blitz]: "#ddce20",
[TimeControl.Other]: "#ea5f17",
};
+109
View File
@@ -0,0 +1,109 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: light) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
/* Search input clear icon */
input[type="search"]::-ms-clear {
display: none;
width: 0;
height: 0;
}
input[type="search"]::-ms-reveal {
display: none;
width: 0;
height: 0;
}
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
display: none;
}
/* Remove buttons on number input field */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* react-date-picker styles */
.react-datepicker__header--custom {
background-color: inherit;
border-bottom: none;
}
.react-datepicker__header {
padding: 8px 0 0px;
position: relative;
text-align: center;
}
.react-datepicker__day {
cursor: default;
}
.react-datepicker__week,
.react-datepicker__day-names {
display: flex;
}
.react-datepicker__day,
.react-datepicker__day-name,
.react-datepicker__time-name {
display: inline-block;
line-height: 1.7rem;
margin: 0.166rem;
text-align: center;
}
.react-datepicker__month {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__day-names,
.react-datepicker__week {
white-space: nowrap;
}
.react-datepicker__aria-live {
order: 0;
-webkit-clip-path: circle(0);
clip-path: circle(0);
height: 0px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 0px;
}
.react-datepicker-popper[data-placement^="bottom"] {
padding-top: 10px;
}
.no-calendar::-webkit-calendar-picker-indicator {
display: none;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "@/../tailwind.config.js";
const config = resolveConfig(tailwindConfig);
const isSSR =
typeof window === "undefined" ||
!window.navigator ||
/ServerSideRendering|^Deno\//.test(window.navigator.userAgent);
const isBrowser = !isSSR;
const useIsomorphicEffect = isBrowser ? useLayoutEffect : useEffect;
export type CreatorReturnType = {
useBreakpoint<B>(breakpoint: B, defaultValue?: boolean): boolean;
useBreakpointEffect<B>(breakpoint: B, effect: (match: boolean) => void): void;
useBreakpointValue<B, T, U>(breakpoint: B, valid: T, invalid: U): T | U;
};
function create(screens: object | undefined) {
if (!screens) {
throw new Error(
"Failed to create breakpoint hooks, given `screens` value is invalid.",
);
}
function useBreakpoint(breakpoint: string, defaultValue: boolean = false) {
const [match, setMatch] = useState(() => defaultValue);
const matchRef = useRef(defaultValue);
useIsomorphicEffect(() => {
if (!(isBrowser && "matchMedia" in window)) return undefined;
function track() {
// @ts-expect-error accessing index with uncertain `screens` type
const value = (screens[breakpoint] as string) ?? "999999px";
const query = window.matchMedia(`(min-width: ${value})`);
matchRef.current = query.matches;
if (matchRef.current != match) {
setMatch(matchRef.current);
}
}
window.addEventListener("resize", track);
track();
return () => window.removeEventListener("resize", track);
});
return match;
}
function useBreakpointEffect<Breakpoint extends string>(
breakpoint: Breakpoint,
effect: (match: boolean) => void,
) {
const match = useBreakpoint(breakpoint);
useEffect(() => effect(match));
return null;
}
function useBreakpointValue<Breakpoint extends string, T, U>(
breakpoint: Breakpoint,
valid: T,
invalid: U,
) {
const match = useBreakpoint(breakpoint);
const value = useMemo(
() => (match ? valid : invalid),
[invalid, match, valid],
);
return value;
}
return {
useBreakpoint,
useBreakpointEffect,
useBreakpointValue,
} as CreatorReturnType;
}
export const { useBreakpoint, useBreakpointEffect, useBreakpointValue } =
create(config.theme!.screens);
+30
View File
@@ -0,0 +1,30 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
function useDarkMode(): [string, Dispatch<SetStateAction<string>>] {
const prefersDarkMode =
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
const [theme, setTheme] = useState(
typeof window !== "undefined"
? localStorage.theme || (prefersDarkMode ? "dark" : "light")
: "dark",
);
const colorTheme = theme === "dark" ? "light" : "dark";
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove(colorTheme);
root.classList.add(theme);
if (typeof window !== "undefined") {
localStorage.setItem("theme", theme);
}
}, [colorTheme, theme]);
return [colorTheme, setTheme];
}
export default useDarkMode;
+49
View File
@@ -0,0 +1,49 @@
export const useDynamicViewportUnits = () => {
const setVh = function () {
var svh = document.documentElement.clientHeight * 0.01;
document.documentElement.style.setProperty("--1svh", svh + "px");
var dvh = window.innerHeight * 0.01;
document.documentElement.style.setProperty("--1dvh", dvh + "px");
};
const isMobile = function () {
if (
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.userAgent.match(/Mac/) &&
navigator.maxTouchPoints &&
navigator.maxTouchPoints > 2)
) {
return true;
}
return false;
};
// SSR support
if (typeof window === "undefined") {
return;
}
// Don't run polyfill if browser supports the units natively
if (
"CSS" in window &&
"supports" in window.CSS &&
window.CSS.supports("height: 100svh") &&
window.CSS.supports("height: 100dvh") &&
window.CSS.supports("height: 100lvh")
) {
return;
}
// Don't run on desktop browsers
if (!isMobile) {
return;
}
window.addEventListener("resize", setVh);
setVh();
return () => {
window.removeEventListener("resize", setVh);
};
};
+5
View File
@@ -0,0 +1,5 @@
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default,
}));
Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

+29
View File
@@ -0,0 +1,29 @@
import { Marker } from "leaflet";
declare class BouncingOptions {
bounceHeight: number;
contractHeight: number;
bounceSpeed: number;
contractSpeed: number;
shadowAngle: number;
elastic: number;
exclusive: number;
constructor(options: Partial<BouncingOptions>);
}
// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing
// to keep Typescript happy
export declare class BouncingMarker extends Marker {
isBouncing(): boolean;
bounce(): void;
stopBouncing(): void;
getBouncingMarkers(): Marker[];
setBouncingOptions(options: BouncingOptions | Partial<BouncingOptions>): void;
stopAllBouncingMarkers(): void;
_icon: HTMLElement;
_bouncingMotion?: {
bouncingAnimationPlaying: boolean;
};
}
+34
View File
@@ -0,0 +1,34 @@
import { MongoClient } from "mongodb";
if (!process.env.MONGODB_URI) {
throw new Error('Invalid environment variable: "MONGODB_URI"');
}
const uri = process.env.MONGODB_URI;
const options = {};
let client;
let clientPromise: Promise<MongoClient>;
if (!process.env.MONGODB_URI) {
throw new Error("Please add your Mongo URI to .env.local");
}
if (process.env.NODE_ENV === "development") {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
//@ts-ignore
if (!global._mongoClientPromise) {
client = new MongoClient(uri, options);
//@ts-ignore
global._mongoClientPromise = client.connect();
}
//@ts-ignore
clientPromise = global._mongoClientPromise;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
clientPromise = client.connect();
}
export default clientPromise;
+84
View File
@@ -0,0 +1,84 @@
function round(n: number) {
return Math.round(n * 10) / 10;
}
function polarToCartesian(radius: number, angleInDegrees: number) {
var radians = ((angleInDegrees - 90) * Math.PI) / 180;
return {
x: round(radius + radius * Math.cos(radians)),
y: round(radius + radius * Math.sin(radians)),
};
}
function getDAttribute(radius: number, startAngle: number, endAngle: number) {
const isCircle = endAngle - startAngle === 360;
if (isCircle) endAngle--;
const start = polarToCartesian(radius, startAngle);
const end = polarToCartesian(radius, endAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1;
const d = [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
largeArcFlag,
1,
end.x,
end.y,
];
if (isCircle) {
d.push("Z");
} else {
d.push("L", radius, radius, "L", start.x, start.y, "Z");
}
return d.join(" ");
}
function path(d: string, colour: string) {
return `<path d='${d}' style="fill: ${colour}" />`;
}
function svg(className: string, width: number, content: string) {
return `<svg class="${className}" viewBox="0 0 ${width} ${width}"><g class='pie'>${content}</g></svg>`;
}
type PieSlice = {
value: number;
colour: string;
};
export function generatePieSVG(
className: string,
radius: number,
values: PieSlice[],
) {
type Sector = {
colour: string;
degrees: number;
from?: number;
to?: number;
};
const total = values.reduce((a, b) => a + b.value, 0);
const data: Sector[] = values.map(({ value, colour }) => ({
colour,
degrees: (value / total) * 360,
}));
const paths = data.reduce<[number, string][]>((prev, value, i) => {
const from = i === 0 ? 0 : prev[i - 1][0];
const to = from + value.degrees;
return [...prev, [to, path(getDAttribute(radius, from, to), value.colour)]];
}, []);
return svg(className, radius * 2, paths.map(([to, path]) => path).join(""));
}
+21
View File
@@ -0,0 +1,21 @@
const sendMail = async ({
email,
subject,
message,
}: Record<string, string>) => {
const data = {
email: email,
subject: subject,
message: message,
};
return await fetch("/api/send-mail", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
};
export default sendMail;
+186
View File
@@ -0,0 +1,186 @@
{
"Metadata": {
"title": "Échecs France - A Map of Chess Tournaments and Clubs in France",
"description": "Find chess tournaments and clubs, in France, on a map",
"keywords": "chess, France, tournament, tournaments, FFE, map, norm, norms, club, clubs"
},
"App": {
"shortDate": "{date, date, ::dd/MM/yyyy}",
"datePlaceholder": "DD/MM/YYYY",
"dateParseFormat": "dd/MM/yyyy",
"dateMask": "99/99/9999",
"selectPlaceholder": "Select...",
"searchPlaceholder": "Search...",
"noOptionsMessage": "No options found",
"timeControlEnum": "{tc, select, Classic {Classic} Rapid {Rapid} Blitz {Blitz} Other {Other} other {{tc}}}"
},
"Nav": {
"title": "Échecs France",
"tournaments": "Tournaments",
"clubs": "Clubs",
"elo": "Elo Calculator"
},
"Footer": {
"githubAria": "Link to GitHub repository",
"contactAria": "Contact Us"
},
"FormValidation": {
"required": "Required",
"url": "Invalid URL"
},
"Home": {
"title": "Échecs France",
"purpose": "Find chess tournaments, in France, on a map",
"how": "A graphical representation of all the chess tournaments published by the <link>FFE</link>",
"mapLink": "See the Map"
},
"Tournaments": {
"loading": "Loading...",
"searchLabel": "Search",
"searchPlaceholder": "Search",
"syncWithMapCheckbox": "Tournaments visible on the map",
"normsOnly": "Only norm tournaments",
"noneFound": "No tournaments found",
"date": "Date",
"town": "Town",
"ffe": "FFE",
"tournament": "Tournament",
"timeControl": "Time Control",
"approx": "Geo-location is approximative",
"norm": "Norm Tournament",
"clearButton": "Clear"
},
"Clubs": {
"loading": "Loading...",
"searchLabel": "Search",
"searchPlaceholder": "Search",
"syncWithMapCheckbox": "Clubs visible on the map",
"noneFound": "No clubs found",
"name": "Name",
"ffe": "FFE",
"contact": "Contact info",
"approx": "Geo-location is approximative",
"clearButton": "Effacer"
},
"About": {
"title": "Who are we?",
"p1": "This project came to life in early 2022 with the aim of providing a visualization of chess tournaments on a map in France. Having moved to France in 2019, I was not familiar with the geography of the country and wanted to know which tournaments were taking place near my location.",
"p2": "The project was put on hold until spring 2023, when I revived it. Rebuilt from scratch, Échecs France is now open source and open to contributions. You can find more information and access the project at <homeLink>Échecs France</homeLink>",
"p3": " I plan to implement an online donation button for those who wish to contribute to the expenses associated with the website. Once the operating costs are deducted, any remaining funds will be redirected to the chess community, either by sponsoring events or through the creation of donations.",
"thanksTitle": "Thanks",
"thanksInfo": "This project is what it is thanks to the contributions of all of you. I would like to extend special thanks to the following individuals for their contributions:",
"techTitle": "Tech",
"techInfo": "<homeLink>Échecs France</homeLink> uses\u00a0:",
"techWith": "with",
"contribInfo": "For more information on how to contribute, please visit our <link>GitHub</link>."
},
"Contact": {
"title": "Contact Us",
"info": "Would you like to add tournaments from your federation to this website? Are you experiencing any technical issues? Would you like to participate in this project? Please feel free to contact us.",
"emailLabel": "Email",
"emailPlaceholder": "name@example.com",
"subjectLabel": "Subject",
"subjectPlaceholder": "Your reason for contacting us",
"messageLabel": "Your Message",
"messagePlaceholder": "Please provide the details of your request here...",
"sendButton": "Send Message",
"sending": "Sending...",
"success": "Thank you for your message.",
"failure": "Sorry, something went wrong. Please try again."
},
"AddTournament": {
"title": "Add a Tournament",
"info": "Fill in the form below and your tournament will be reviewed shortly.",
"tournamentNameLabel": "Tournament Name",
"tournamentNamePlaceholder": "Tournament name",
"dateLabel": "Date",
"urlLabel": "URL",
"urlPlaceholder": "www.example.com",
"tcLabel": "Time Control",
"normLabel": "Norm Tournament?",
"normNo": "No",
"normYes": "Yes",
"addressLabel": "Address",
"addressPlaceholder": "Address",
"townLabel": "Town",
"townPlaceholder": "Town",
"departmentLabel": "County",
"departmentPlaceholder": "County",
"postcodeLabel": "Postcode",
"postcodePlaceholder": "Postcode",
"countryLabel": "Country",
"countryPlaceholder": "Country",
"latLabel": "Latitude",
"lngLabel": "Longitude",
"yourNameLabel": "Your Name",
"yourNamePlaceholder": "Your Name",
"yourEmailLabel": "Your Email",
"yourEmailPlaceholder": "Your Email",
"messageLabel": "Additional Info / Message",
"messagePlaceholder": "Additional Info / Message",
"sendButton": "Submit Tournament",
"sending": "Sending...",
"clearForm": "Clear Form",
"success": "Thank you for your message.",
"failure": "Sorry, something went wrong. Please try again."
},
"Elo": {
"title": "Elo Calculator",
"info": "Use this calculator to give yourself an idea of your new Elo rating after a tournament.",
"useManualLabel": "or enter your results manually",
"currentEloLabel": "Your current Elo",
"currentEloPlaceholder": "Elo",
"yourKFactorLabel": "Your K-Factor",
"kFactorLabel": "Coefficient",
"kFactorPlaceholder": "K-Factor",
"resultsTitle": "Results",
"opponentEloPlaceholder": "Opponent Elo",
"gameResultPlaceholder": "Result...",
"gameResult": "{result, select, win {Win} draw {Draw} loss {Loss} other {{result}}}",
"addGameButton": "Add another game result",
"resultsUrlLabel": "Paste a link to the FFE results page",
"resultsUrlPlaceholder": "http://echecs.asso.fr/FicheTournoi.aspx?Ref=...",
"fetchResultsButton": "Fetch",
"enterManualResultsButton": "Enter results manually",
"choosePlayerLabel": "Choose a player",
"choosePlayerPlaceholder": "Player",
"needRating": "This calculator requires an initial FIDE rating. The selected player has either an estimated rating or a National rating.",
"forfeit": "Forfeit",
"vs": " vs ",
"wonByForfeit": "Won by forfeit",
"lostByForfeit": "Lost by forfeit",
"draw": "½ - ½",
"whiteWin": "1 - 0",
"blackWin": "0 - 1",
"initialRating": "Initial Rating: {rating}",
"finalRating": "Final Rating: {rating} (<delta></delta>)",
"noChangeInfo": "Note that {noChangeCount, plural, one {one of the games} other {# of the games}} resulted in no change to the Elo. Forfeited games and games played against players that do not yet have a FIDE rating are not included in the calculation.",
"kFactorTitle": "What is the K-Factor?",
"kFactorInfo1": "The purpose of the K-factor is to determine how much a player's chess rating should change after each game. It is based on the number of rated games a player has completed. This system helps ensure that players' ratings accurately reflect their skill levels while accommodating both new and experienced players.",
"kFactorInfo2": "Under FIDE, the K-factor is:",
"kFactorInfo3": "40 for a player new to the rating list until he has completed events with at least 30 games.",
"kFactorInfo4": "20 as long as a player's rating remains under 2400.",
"kFactorInfo5": "10 once a player's published rating has reached 2400 and remains at that level subsequently, even if the rating drops below 2400.",
"kFactorInfo6": "40 for all players until their 18th birthday, as long as their rating remains under 2300.",
"kFactorInfo7": "20 for RAPID and BLITZ ratings for all players.",
"unknownError": "We were unable to fetch results from this URL. Please <contact>contact us</contact> if the problem persists.",
"ERR_TOURNAMENT_NOT_FOUND": "Tournament not found",
"ERR_NO_TOURNAMENT_ID": "The link you provided is incorrect"
}
}
+188
View File
@@ -0,0 +1,188 @@
{
"Metadata": {
"title": "Échecs France - Une Carte des Tournois d'Échecs et Clubs d'Échecs en France",
"description": "Trouvez vos tournois d'échecs et clubs d'échecs en France sur une carte",
"keywords": "échecs, échec, d'échecs, France, tournoi, tournois, FFE, carte, norme, normes, club, clubs"
},
"App": {
"shortDate": "{date, date, ::dd/MM/yyyy}",
"datePlaceholder": "DD/MM/YYYY",
"dateParseFormat": "dd/MM/yyyy",
"dateMask": "99/99/9999",
"selectPlaceholder": "Choisir...",
"searchPlaceholder": "Rechercher...",
"noOptionsMessage": "Pas d'options",
"timeControlEnum": "{tc, select, Classic {Cadence Lente} Rapid {Rapide} Blitz {Blitz} Other {Autres} other {{tc}}}"
},
"Nav": {
"title": "Échecs France",
"tournaments": "Tournois",
"clubs": "Clubs",
"elo": "Calculette Elo"
},
"Footer": {
"githubAria": "Lien vers le dépôt GitHub",
"contactAria": "Contactez-nous"
},
"FormValidation": {
"required": "Requis",
"url": "L'URL n'est pas valide"
},
"Home": {
"title": "Échecs France",
"purpose": "Trouvez vos tournois d'échecs en france sur une carte",
"how": "Une représentation visuelle de tous les tournois d'échecs publiés par la <link>FFE</link>",
"mapLink": "Voir La Carte"
},
"Tournaments": {
"loading": "Téléchargement...",
"searchLabel": "Rechercher",
"searchPlaceholder": "Rechercher",
"syncWithMapCheckbox": "Tournois visibles sur la carte",
"normsOnly": "Uniquement tournois à normes",
"noneFound": "Pas de tournois trouvé",
"date": "Date",
"town": "Ville",
"ffe": "FFE",
"tournament": "Tournois",
"timeControl": "Cadence",
"approx": "Géolocalisation approximative",
"norm": "Tournoi à norme",
"clearButton": "Effacer"
},
"Clubs": {
"loading": "Téléchargement...",
"searchLabel": "Rechercher",
"searchPlaceholder": "Rechercher",
"syncWithMapCheckbox": "Clubs visibles sur la carte",
"noneFound": "Pas de club trouvé",
"name": "Name",
"ffe": "FFE",
"contact": "Contact",
"approx": "Géolocalisation approximative",
"clearButton": "Effacer"
},
"About": {
"title": "Qui Sommes-Nous?",
"p1": "Ce projet a vu le jour début 2022 afin de permettre une visualisation sur une carte des tournois d'échecs en France. Ayant déménagé en France en 2019, je ne connaissais alors pas la géographie française et je souhaitais savoir quels tournois avaient lieu près de chez moi.",
"p2": "Le projet a été mis de côté jusqu'au printemps 2023; date à laquelle je lui ai redonné vie. Reconstruit à partir de zéro, <homeLink>Échecs France</homeLink> est désormais open source et ouvert aux contributions.",
"p3": "Je compte mettre en place un bouton de don en ligne pour ceux qui souhaitent participer aux frais associés au site. Une fois les coûts de fonctionnement déduits, tous les fonds restant seront redirigés vers le monde des échecs soit en sponsorant des événements ou par la création de dons.",
"thanksTitle": "Remerciements",
"thanksInfo": "Ce projet est ce qu'il est grâce aux contributions de vous tous. Je souhaite en particulier remercier les personnes suivantes pour leurs contributions\u00a0:",
"techTitle": "Tech",
"techInfo": "<homeLink>Échecs France</homeLink> utilise\u00a0:",
"techWith": "avec",
"contribInfo": "Pour plus d'informations sur les moyens de contribution, veuillez visiter notre <link>GitHub</link>."
},
"Contact": {
"title": "Contactez-Nous",
"info": "Vous souhaitez ajouter les tournois de votre fédération sur ce site\u00a0? Vous avez un problème technique? Vous aimeriez participer à ce projet? Contactez-nous.",
"emailLabel": "Adresse e-mail",
"emailPlaceholder": "nom@exemple.com",
"subjectLabel": "Sujet",
"subjectPlaceholder": "Le motif de ma demande",
"messageLabel": "Votre message",
"messagePlaceholder": "Détaillez ici votre demande...",
"sendButton": "Envoi Message",
"sending": "Envoi en cours...",
"success": "Merci pour votre message.",
"failure": "Oops, quelque chose ne va pas. Veuillez réessayer SVP."
},
"AddTournament": {
"title": "Ajouter un Tournoi",
"info": "Veuillez remplir le formulaire ci-dessous et votre tournoi sera validé très prochainement.",
"tournamentNameLabel": "Nom du Tournoi",
"tournamentNamePlaceholder": "Nom du tournoi",
"dateLabel": "Date",
"urlLabel": "URL",
"urlPlaceholder": "www.example.com",
"tcLabel": "Cadence",
"tcOption1": "Cadence Lente",
"normLabel": "Tournoi à norme\u00a0?",
"normNo": "Non",
"normYes": "Oui",
"addressLabel": "Adresse",
"addressPlaceholder": "Adresse",
"townLabel": "Ville",
"townPlaceholder": "Ville",
"departmentLabel": "Département",
"departmentPlaceholder": "Département",
"postcodeLabel": "Code Postal",
"postcodePlaceholder": "Code Postal",
"countryLabel": "Pays",
"countryPlaceholder": "Pays",
"latLabel": "Latitude",
"lngLabel": "Longitude",
"yourNameLabel": "Votre Nom",
"yourNamePlaceholder": "Votre Nom",
"yourEmailLabel": "Votre adresse mail",
"yourEmailPlaceholder": "Votre adresse mail",
"messageLabel": "Information complémentaire",
"messagePlaceholder": "Additional Info",
"sendButton": "Envoi",
"sending": "Envoi en cours...",
"clearForm": "Réinitialiser",
"success": "Merci pour votre message.",
"failure": "Oops, quelque chose ne va pas. Veuillez réessayer SVP."
},
"Elo": {
"title": "Calculette Elo",
"info": "Utilisez ce calculateur pour estimer votre nouveau classement Elo après un tournoi.",
"useManualLabel": "ou saisissez vos résultats manuellement",
"currentEloLabel": "Votre Elo actuel",
"currentEloPlaceholder": "Elo",
"yourKFactorLabel": "Votre coefficient K",
"kFactorLabel": "Coefficient K",
"resultsTitle": "Résultats",
"opponentEloPlaceholder": "Classement de l'adversaire",
"gameResultPlaceholder": "Résultat...",
"gameResult": "{result, select, win {Victoire} draw {Nul} loss {Défaite} other {{result}}}",
"addGameButton": "Ajouter un autre résultat de partie",
"resultsUrlLabel": "Collez un lien vers la page de résultats de la FFE",
"resultsUrlPlaceholder": "http://echecs.asso.fr/FicheTournoi.aspx?Ref=...",
"fetchResultsButton": "Récupérer",
"enterManualResultsButton": "Saisir les résultats manuellement",
"choosePlayerLabel": "Choisissez un joueur",
"choosePlayerPlaceholder": "Joueur",
"needRating": "Ce calculateur nécessite un classement FIDE initial. Le joueur sélectionné a soit un classement estimé, soit un classement national.",
"forfeit": "Forfait",
"vs": " - ",
"wonByForfeit": "Gagné par forfait",
"lostByForfeit": "Perdu par forfait",
"draw": "½ - ½",
"whiteWin": "1 - 0",
"blackWin": "0 - 1",
"initialRating": "Classement initial\u00a0: {rating}",
"finalRating": "Classement final\u00a0: {rating} (<delta></delta>)",
"noChangeInfo": "Notez que {noChangeCount, plural, one {une de ces parties n'a pas modifié l'Elo.} other {# de ces parties n'ont pas modifié l'Elo.}}. Les parties perdues par forfait et les parties jouées contre des joueurs qui n'ont pas encore de classement FIDE ne sont pas prises en compte dans le calcul.",
"kFactorTitle": "Qu'est-ce que le coefficient K\u00a0?",
"kFactorInfo1": "Le but du coefficient K est de déterminer dans quelle mesure le classement aux échecs d'un joueur devrait changer après chaque partie. Il est basé sur le nombre de parties classées qu'un joueur a complétées. Ce système vise à garantir que les classements des joueurs reflètent avec précision leur niveau de compétence tout en tenant compte à la fois des joueurs débutants et expérimentés.",
"kFactorInfo2": "Sous la FIDE, le coefficient K est le suivant\u00a0:",
"kFactorInfo3": "<b>40</b> pour un joueur nouvellement inscrit au classement tant qu'il n'a pas participé à des événements avec au moins 30 parties.",
"kFactorInfo4": "<b>20</b> tant que le classement d'un joueur reste inférieur à 2400.",
"kFactorInfo5": "<b>10</b> une fois que le classement publié d'un joueur a atteint 2400 et reste à ce niveau par la suite, même s'il redescend en dessous de 2400.",
"kFactorInfo6": "<b>40</b> pour tous les joueurs jusqu'à leur 18e anniversaire, tant que leur classement reste inférieur à 2300.",
"kFactorInfo7": "<b>20</b> pour les classements RAPIDE et BLITZ pour tous les joueurs.",
"unknownError": "Nous n'avons pas pu récupérer les résultats à partir de cette URL. Veuillez <contact>nous contacter</contact> si le problème persiste.",
"ERR_TOURNAMENT_NOT_FOUND": "Tournoi introuvable",
"ERR_NO_TOURNAMENT_ID": "L'identifiant du tournoi fourni est incorrect"
}
}
+17
View File
@@ -0,0 +1,17 @@
import createMiddleware from "next-intl/middleware";
import { locales, pathnames } from "@/utils/navigation";
export default createMiddleware({
defaultLocale: "fr",
locales,
pathnames,
localePrefix: "as-needed",
});
export const config = {
// Skip all paths that should not be internationalized. This example skips certain folders
// such as "api", and all files with an extension (e.g. favicon.ico)
matcher: ["/((?!api|_next|robots|sitemap|.*\\..*).*)"],
};
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getFetch, httpBatchLink, loggerLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "@/utils/trpc";
export const TrpcProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { staleTime: 5000 } },
}),
);
const url = "/api/trpc";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: () => true,
}),
httpBatchLink({
url,
fetch: async (input, init?) => {
const fetch = getFetch();
return fetch(input, {
...init,
credentials: "include",
});
},
}),
],
transformer: superjson,
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
};
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { Provider } from "jotai";
import { useDynamicViewportUnits } from "@/hooks/useDynamicViewportUnits";
import { TrpcProvider } from "./TrpcProvider";
export default function Providers({ children }: { children: React.ReactNode }) {
useDynamicViewportUnits();
return (
<Provider>
<TrpcProvider>{children}</TrpcProvider>
</Provider>
);
}
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod";
import { TimeControl } from "@/types";
export const contactUsSchema = z.object({
email: z.string().email(),
subject: z.string().min(1, { message: "FormValidation.required" }),
message: z.string().min(1, { message: "FormValidation.required" }),
});
export const addTournamentSchema = z.object({
name: z.string().min(1, { message: "FormValidation.required" }),
email: z.string().email(),
message: z.string().optional(),
tournament: z.object({
address: z.string().min(1, { message: "FormValidation.required" }),
town: z.string().min(1, { message: "FormValidation.required" }),
department: z.string().min(1, { message: "FormValidation.required" }),
tournament: z.string().min(1, { message: "FormValidation.required" }),
url: z.string().url({ message: "FormValidation.url" }),
time_control: z.nativeEnum(TimeControl),
norm_tournament: z.boolean(),
date: z.date(),
coordinates: z.array(z.number()).length(2),
country: z.string().min(1, { message: "FormValidation.required" }),
}),
});
export const fetchTournamentResultsSchema = z.object({
url: z.string().url({ message: "FormValidation.url" }),
});
+12
View File
@@ -0,0 +1,12 @@
import { addTournament } from "./procedures/addTournament";
import { contactUs } from "./procedures/contactUs";
import { fetchTournamentResults } from "./procedures/fetchTournamentResults";
import { router } from "./trpc";
export const appRouter = router({
contactUs,
addTournament,
fetchTournamentResults,
});
export type AppRouter = typeof appRouter;
+81
View File
@@ -0,0 +1,81 @@
import { format } from "date-fns";
import clientPromise from "@/lib/mongodb";
import { addTournamentSchema } from "@/schemas";
import { TournamentData } from "@/types";
import { TimeControl } from "@/types";
import { errorLog } from "@/utils/logger";
import { publicProcedure } from "../trpc";
const tcMap: Record<TimeControl, TournamentData["time_control"]> = {
[TimeControl.Classic]: "Cadence Lente",
[TimeControl.Rapid]: "Rapide",
[TimeControl.Blitz]: "Blitz",
[TimeControl.Other]: "Other",
};
export const addTournament = publicProcedure
.input(addTournamentSchema)
.mutation(async ({ input }) => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB").collection("tournaments");
const { name, email, message, tournament } = input;
const tournamentData: Omit<TournamentData, "_id"> = {
...tournament,
date: format(tournament.date, "dd/MM/yyyy"),
time_control: tcMap[tournament.time_control],
coordinates: tournament.coordinates as [number, number],
entry_method: "manual",
pending: true,
status: "scheduled",
};
const result = await db.insertOne(tournamentData);
if (result.insertedId) {
const { tournament, country, date, time_control } = tournamentData;
if (
typeof process.env.DISCORD_WEBHOOK_ADD_TOURNAMENT_URL === "string"
) {
await fetch(
process.env.DISCORD_WEBHOOK_ADD_TOURNAMENT_URL as string,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
embeds: [
{
title: "Tournament Submitted",
fields: [
{ name: "Tournament", value: tournament },
{ name: "Country", value: country },
{ name: "Date", value: date },
{
name: "Time Control",
value: time_control,
},
{ name: "Sender", value: name },
{ name: "Sender Email", value: email },
{ name: "Message", value: message ?? "" },
],
},
],
}),
},
);
}
return true;
}
} catch (error) {
errorLog(error);
throw error;
}
});
+38
View File
@@ -0,0 +1,38 @@
import NodeMailer from "nodemailer";
import { contactUsSchema } from "@/schemas";
import { errorLog, infoLog } from "@/utils/logger";
import { publicProcedure } from "../trpc";
export const contactUs = publicProcedure
.input(contactUsSchema)
.mutation(async ({ input }) => {
try {
const { email, subject, message } = input;
const mailContent = {
from: email,
to: process.env.GMAIL_USER,
subject: `${subject} from ${email}`,
text: message,
html: `<p>${message}</p>`,
};
const transporter = NodeMailer.createTransport({
service: "gmail",
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_PASS,
},
});
const mailInfo = await transporter.sendMail(mailContent);
infoLog(mailInfo);
return true;
} catch (error) {
errorLog(error);
throw error;
}
});
@@ -0,0 +1,165 @@
import { z } from "zod";
import { fetchTournamentResultsSchema } from "@/schemas";
import { errorLog } from "@/utils/logger";
import { publicProcedure } from "../trpc";
const scrapedSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
elo: z.string(),
results: z.array(
z.object({
colour: z.enum(["B", "N", ""]),
result: z.string(),
opponent_name: z.string().nullable(),
opponent_elo: z.string().nullable(),
}),
),
}),
);
const outputSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
elo: z.number(),
estimated: z.boolean(),
national: z.boolean(),
results: z.array(
z.object({
colour: z.enum(["white", "black", ""]),
result: z.number().nullable(),
lostByForfeit: z.boolean(),
wonByForfeit: z.boolean(),
opponent: z.string().nullable(),
elo: z.number().nullable(),
estimated: z.boolean(),
national: z.boolean(),
}),
),
}),
);
const getEloDetails = (elo: string | null) => {
if (elo === null || elo === "") {
return { elo: 0, estimated: false, national: false };
}
return {
elo: parseInt(elo),
estimated: elo.endsWith("E"),
national: elo.endsWith("N"),
};
};
const getResultDetails = (
results: z.infer<typeof scrapedSchema>[number]["results"][number],
) => {
if (results.opponent_name === null) {
return { result: 0, lostByForfeit: true, wonByForfeit: false };
}
if (results.result === "<") {
return { result: 0, lostByForfeit: true, wonByForfeit: false };
}
if (results.result === ">") {
return { result: 1, lostByForfeit: false, wonByForfeit: true };
}
return {
result: parseFloat(results.result),
lostByForfeit: false,
wonByForfeit: false,
};
};
const reportFetchError = async (url: string, error: any) => {
if (typeof process.env.DISCORD_WEBHOOK_ERROR_LOGS_URL === "string") {
await fetch(process.env.DISCORD_WEBHOOK_ERROR_LOGS_URL as string, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
embeds: [
{
title: "Error Fetching Tournament Results",
fields: [
{ name: "url", value: url },
{
name: "error.message",
value: "message" in error ? error.message : "",
},
{ name: "error", value: JSON.stringify(error, null, 2) },
],
},
],
}),
});
}
};
export const fetchTournamentResults = publicProcedure
.input(fetchTournamentResultsSchema)
.output(outputSchema)
.query(async ({ input }) => {
try {
// Depending on which page the user copied the URL from, we need to extract the tournament ID
// http://echecs.asso.fr/FicheTournoi.aspx?Ref=59975
// http://echecs.asso.fr/Resultats.aspx?URL=Tournois/Id/59975/59975&Action=Ls
const tournamentId =
input.url.match(/Ref=(\d+)/)?.[1] ?? input.url.match(/Id\/(\d+)/)?.[1];
if (!tournamentId) {
throw new Error("ERR_NO_TOURNAMENT_ID");
}
const headers = new Headers();
const apiKey = process.env.RESULTS_API_KEY;
if (apiKey) {
headers.append("api-key", apiKey);
}
const rawResults = await fetch(
`${process.env.RESULTS_SCRAPER_URL}${tournamentId}`,
{
headers: headers,
},
);
const results = await rawResults.json();
if ("detail" in results && results.detail === "Not found") {
throw new Error("ERR_TOURNAMENT_NOT_FOUND");
}
const parsedResults = scrapedSchema.parse(results);
return parsedResults.map<z.infer<typeof outputSchema>[number]>(
(player) => ({
id: player.id,
name: player.name,
...getEloDetails(player.elo),
results: player.results.map((result) => ({
colour:
result.colour === "B"
? "white"
: result.colour === "N"
? "black"
: "",
opponent: result.opponent_name,
...getResultDetails(result),
...getEloDetails(result.opponent_elo),
})),
}),
);
} catch (error) {
reportFetchError(input.url, error);
errorLog(JSON.stringify(error, null, 2));
throw error;
}
});
+11
View File
@@ -0,0 +1,11 @@
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod";
const t = initTRPC.create({
transformer: superjson,
});
// Base router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
+77
View File
@@ -0,0 +1,77 @@
import { LatLngLiteral } from "leaflet";
import { ObjectId } from "mongodb";
export type Status = "scheduled" | "ongoing" | "finished";
export type TournamentData = {
_id: ObjectId;
town: string;
department: string;
country: string;
tournament: string;
url: string;
time_control: "Cadence Lente" | "Rapide" | "Blitz" | string;
norm_tournament: boolean;
date: string;
coordinates: [number, number];
entry_method: "manual" | "auto";
pending: boolean;
status: Status;
};
export type ClubData = {
_id: ObjectId;
name: string;
url?: string;
address?: string;
email?: string;
website?: string;
coordinates: [number, number];
};
export enum TimeControl {
Classic = "Classic",
Rapid = "Rapid",
Blitz = "Blitz",
Other = "Other",
}
export type Tournament = {
id: string;
groupId: string;
town: string;
department: string;
tournament: string;
url: string;
timeControl: TimeControl;
date: string;
latLng: LatLngLiteral;
norm: boolean;
pending: boolean;
status: Status;
};
export type Club = {
id: string;
name: string;
url?: string;
address?: string;
email?: string;
website?: string;
latLng: LatLngLiteral;
};
export type ResponseMessage = {
isSuccessful: boolean;
message: string;
};
export type ScrollableElement = Window | HTMLElement;
// Prettify takes a type as its argument and returns a new type that has the same properties as the original type,
// but the properties are not intersected. This means that the new type is easier to read and understand.
// https://gist.github.com/palashmon/db68706d4f26d2dbf187e76409905399
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
+66
View File
@@ -0,0 +1,66 @@
import { SetStateAction, atom } from "jotai";
export default function atomWithDebounce<T>(
initialValue: T,
delayMilliseconds = 500,
delayOnResetMilliseconds = 500,
) {
const prevTimeoutAtom = atom<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
// DO NOT EXPORT currentValueAtom as using this atom to set state can cause
// inconsistent state between currentValueAtom and debouncedValueAtom
const _currentValueAtom = atom(initialValue);
const isDebouncingAtom = atom(false);
const debouncedValueAtom = atom(
initialValue,
(get, set, update: SetStateAction<T>) => {
clearTimeout(get(prevTimeoutAtom));
const prevValue = get(_currentValueAtom);
const nextValue =
typeof update === "function"
? (update as (prev: T) => T)(prevValue)
: update;
const onDebounceStart = () => {
set(_currentValueAtom, nextValue);
set(isDebouncingAtom, true);
};
const onDebounceEnd = () => {
set(debouncedValueAtom, nextValue);
set(isDebouncingAtom, false);
};
onDebounceStart();
const nextTimeoutId = setTimeout(
() => {
onDebounceEnd();
},
nextValue === initialValue
? delayOnResetMilliseconds
: delayMilliseconds,
);
// set previous timeout atom in case it needs to get cleared
set(prevTimeoutAtom, nextTimeoutId);
},
);
// exported atom setter to clear timeout if needed
const clearTimeoutAtom = atom(null, (get, set) => {
clearTimeout(get(prevTimeoutAtom));
set(isDebouncingAtom, false);
});
return {
currentValueAtom: atom((get) => get(_currentValueAtom)),
isDebouncingAtom,
clearTimeoutAtom,
debouncedValueAtom,
};
}
+86
View File
@@ -0,0 +1,86 @@
import { clamp, last } from "lodash";
// Fide uses teh following table to establish the chance of winning based on the difference in elo.
// https://handbook.fide.com/chapter/B022017
const differenceToProbability: [
start: number,
end: number,
winChance: number,
][] = [
[0, 3, 0.5],
[4, 10, 0.51],
[11, 17, 0.52],
[18, 25, 0.53],
[26, 32, 0.54],
[33, 39, 0.55],
[40, 46, 0.56],
[47, 53, 0.57],
[54, 61, 0.58],
[62, 68, 0.59],
[69, 76, 0.6],
[77, 83, 0.61],
[84, 91, 0.62],
[92, 98, 0.63],
[99, 106, 0.64],
[107, 113, 0.65],
[114, 121, 0.66],
[122, 129, 0.67],
[130, 137, 0.68],
[138, 145, 0.69],
[146, 153, 0.7],
[154, 162, 0.71],
[163, 170, 0.72],
[171, 179, 0.73],
[180, 188, 0.74],
[189, 197, 0.75],
[198, 206, 0.76],
[207, 215, 0.77],
[216, 225, 0.78],
[226, 235, 0.79],
[236, 245, 0.8],
[246, 256, 0.81],
[257, 267, 0.82],
[268, 278, 0.83],
[279, 290, 0.84],
[291, 302, 0.85],
[303, 315, 0.86],
[316, 328, 0.87],
[329, 344, 0.88],
[345, 357, 0.89],
[358, 374, 0.9],
[375, 391, 0.91],
[392, 411, 0.92],
[412, 432, 0.93],
[433, 456, 0.94],
[457, 484, 0.95],
[485, 517, 0.96],
[518, 559, 0.97],
[560, 619, 0.98],
[620, 735, 0.99],
[736, 1000, 1],
];
export const getNewRating = (
rating: number,
opponentRating: number,
result: 1 | 0 | 0.5,
kFactor: number,
) => {
const differenceInElo = clamp(opponentRating - rating, -400, 400);
const tableRow = (differenceToProbability.find(
([start, end]) =>
Math.abs(differenceInElo) >= start && Math.abs(differenceInElo) <= end,
) ?? last(differenceToProbability)!)[2];
const probabilityOfWinning = differenceInElo < 0 ? tableRow : 1 - tableRow;
const delta =
Math.round(
kFactor * (result - probabilityOfWinning + Number.EPSILON) * 100,
) / 100;
return {
delta,
newRating: Math.round((rating + delta + Number.EPSILON) * 100) / 100,
};
};
+10
View File
@@ -0,0 +1,10 @@
const formatDate = (inputDate: any) => {
// TODO fix type
const date = new Date(inputDate);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
export default formatDate;
+7
View File
@@ -0,0 +1,7 @@
export const infoLog = (...params: any[]) => {
console.log(...params);
};
export const errorLog = (...params: any[]) => {
console.error(...params);
};
+32
View File
@@ -0,0 +1,32 @@
import {
Pathnames,
createLocalizedPathnamesNavigation,
} from "next-intl/navigation";
export const locales = ["fr", "en"] as const;
// The `pathnames` object holds pairs of internal
// and external paths, separated by locale.
export const pathnames = {
"/": "/",
"/clubs": "/clubs",
"/elo": "/elo",
"/tournaments": {
fr: "/tournois",
en: "/tournaments",
},
"/add-tournament": {
fr: "/ajouter-un-tournoi",
en: "/add-tournament",
},
"/contact-us": {
fr: "/contactez-nous",
en: "/contact-us",
},
} satisfies Pathnames<typeof locales>;
export const { Link, redirect, usePathname, useRouter, getPathname } =
createLocalizedPathnamesNavigation({ locales, pathnames });
+8
View File
@@ -0,0 +1,8 @@
import { ScrollableElement } from "@/types";
const isBrowser = () => typeof window !== "undefined";
export const handleScrollToTop = (element: ScrollableElement | null) => {
if (!isBrowser()) return;
element?.scrollTo({ top: 0, behavior: "smooth" });
};
+9
View File
@@ -0,0 +1,9 @@
export const removeDiacritics = (str: string) => {
// We normalize to the composed Unicode form, then we can remove the accents
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
};
export const normalizedContains = (haystack: string, needle: string) => {
const regExp = new RegExp(removeDiacritics(needle), "gi");
return regExp.test(removeDiacritics(haystack));
};
+11
View File
@@ -0,0 +1,11 @@
import { createTRPCReact } from "@trpc/react-query";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@/server/appRouter";
export type APIRouterInput = inferRouterInputs<AppRouter>;
export type APIRouterOutput = inferRouterOutputs<AppRouter>;
export type TournamentResultsData = APIRouterOutput["fetchTournamentResults"];
export const trpc = createTRPCReact<AppRouter>();