mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Reorganise code - use src folder
This commit is contained in:
@@ -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='© <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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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='© <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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TimeControl } from "@/types";
|
||||
|
||||
export const TimeControlColours = {
|
||||
[TimeControl.Classic]: "#00ac39",
|
||||
[TimeControl.Rapid]: "#0086c7",
|
||||
[TimeControl.Blitz]: "#ddce20",
|
||||
[TimeControl.Other]: "#ea5f17",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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 |
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(""));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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|.*\\..*).*)"],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.
@@ -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" }),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
} & {};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
export const infoLog = (...params: any[]) => {
|
||||
console.log(...params);
|
||||
};
|
||||
|
||||
export const errorLog = (...params: any[]) => {
|
||||
console.error(...params);
|
||||
};
|
||||
@@ -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 });
|
||||
@@ -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" });
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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>();
|
||||
Reference in New Issue
Block a user