-
+
@@ -96,7 +68,50 @@ export default function TournamentTable({
-
{tableData}
+
+ {filteredTournaments.length === 0 ? (
+
+ |
+ {t("noneFound")}
+ |
+
+ ) : (
+ filteredTournaments.map((tournament) => (
+ setHoveredListTournamentId(tournament._id)}
+ onMouseLeave={() => setHoveredListTournamentId(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",
+ hoveredMapTournamentId === tournament._id &&
+ "bg-gray-200 dark:bg-gray-900"
+ )}
+ >
+ |
+
+ {tournament.date}
+
+ |
+
+
+ {tournament.town}
+
+ |
+
+
+ {tournament.tournament}
+
+ |
+
+
+ {tournament.time_control}
+
+ |
+
+ ))
+ )}
+
);
diff --git a/app/[lang]/tournois/TournamentsDisplay.tsx b/app/[lang]/tournois/TournamentsDisplay.tsx
new file mode 100644
index 0000000..c9fd9dd
--- /dev/null
+++ b/app/[lang]/tournois/TournamentsDisplay.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useTranslations } from "next-intl";
+import { useHydrateAtoms } from "jotai/utils";
+
+import { Tournament } from "@/types";
+import { tournamentsAtom } from "@/app/atoms";
+
+import TournamentTable from "./TournamentTable";
+
+const LoadingMap = () => {
+ const t = useTranslations("Tournaments");
+ return (
+
+ );
+};
+
+/**
+ * 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,
+});
+
+type TournamentsDisplayProps = {
+ tournaments: Tournament[];
+};
+
+export default async function TournamentsDisplay({
+ tournaments,
+}: TournamentsDisplayProps) {
+ useHydrateAtoms([[tournamentsAtom, tournaments]]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/[lang]/tournois/page.tsx b/app/[lang]/tournois/page.tsx
index 5bb57e2..cb97587 100644
--- a/app/[lang]/tournois/page.tsx
+++ b/app/[lang]/tournois/page.tsx
@@ -1,38 +1,45 @@
import clientPromise from "@/lib/mongodb";
-import dynamic from "next/dynamic";
-import { useTranslations } from "next-intl";
-import { dateOrderingFrance } from "@/utils/dbDateOrdering";
import { errorLog } from "@/utils/logger";
+import { Tournament } from "@/types";
-import TournamentTable from "./TournamentTable";
+import TournamentsDisplay from "./TournamentsDisplay";
-export const revalidate = 3600; // revalidate cache every 6 hours;
-
-const LoadingMap = () => {
- const t = useTranslations("Tournaments");
- return (
-
- );
-};
-
-/**
- * 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 const revalidate = 3600; // Revalidate cache every 6 hours
const getTournaments = async () => {
try {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
- const data = await dateOrderingFrance(db);
- return JSON.stringify(data);
+ const data = await db
+ .collection("tournaments")
+ .aggregate
([
+ {
+ $addFields: {
+ dateParts: {
+ $dateFromString: {
+ dateString: "$date",
+ format: "%d/%m/%Y",
+ },
+ },
+ },
+ },
+ {
+ $sort: {
+ dateParts: 1,
+ },
+ },
+ {
+ $unset: "dateParts",
+ },
+ ])
+ .toArray();
+
+ // We map the ObjectId to a string so that it can be serialized and sent to a client component
+ return data.map((t) => ({
+ ...t,
+ _id: t._id.toString(),
+ }));
} catch (error) {
errorLog(error);
throw new Error("Error fetching tournament data");
@@ -40,16 +47,7 @@ const getTournaments = async () => {
};
export default async function Tournaments() {
- const tournamentData = await getTournaments();
+ const tournaments = await getTournaments();
- return (
-
-
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/app/api/v1/tournaments/france/route.ts b/app/api/v1/tournaments/france/route.ts
deleted file mode 100644
index a09f79b..0000000
--- a/app/api/v1/tournaments/france/route.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import clientPromise from "@/lib/mongodb";
-import { dateOrderingFrance } from "@/utils/dbDateOrdering";
-import { errorLog } from "@/utils/logger";
-
-/**
- * Tournament data API endpoint
- * @route /api/v1/tournaments/france
- * @public
- */
-export const revalidate = 3600; // revalidate cache every 6 hours
-export async function GET() {
- const headers = {
- "Content-Type": "application/json",
- };
- try {
- const client = await clientPromise;
- const db = client.db("tournamentsFranceDB");
- const results = await dateOrderingFrance(db);
- const data = results.map(({ _id, ...rest }) => ({
- id: _id,
- ...rest,
- }));
-
- return new Response(JSON.stringify(data), { status: 200, headers });
- } catch (error) {
- errorLog(error);
- return new Response(JSON.stringify(error), { status: 500, headers });
- }
-}
diff --git a/app/atoms.ts b/app/atoms.ts
new file mode 100644
index 0000000..6488ee4
--- /dev/null
+++ b/app/atoms.ts
@@ -0,0 +1,24 @@
+import { Tournament } from '@/types';
+import { atom } from 'jotai';
+
+import atomWithDebounce from "@/utils/atomWithDebounce";
+
+export const tournamentsAtom = atom([]);
+export const searchStringAtom = atom('');
+
+export const {
+ currentValueAtom: hoveredMapTournamentIdAtom,
+ debouncedValueAtom: debouncedHoveredMapTournamentIdAtom,
+} = atomWithDebounce(null);
+
+export const {
+ debouncedValueAtom: debouncedHoveredListTournamentIdAtom,
+} = atomWithDebounce(null);
+
+export const filteredTournamentsAtom = atom((get) => {
+ const tournaments = get(tournamentsAtom);
+ const searchString = get(searchStringAtom).trim();
+
+ if (searchString === '') return tournaments;
+ return tournaments.filter((t) => t.town.includes(searchString.toUpperCase()))
+})
diff --git a/package-lock.json b/package-lock.json
index ac5ac46..cc4147f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,8 +16,10 @@
"autoprefixer": "10.4.14",
"eslint": "8.43.0",
"eslint-config-next": "^13.4.7",
+ "jotai": "^2.2.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1",
+ "leaflet.smooth_marker_bouncing": "^3.0.3",
"mongodb": "^5.6.0",
"next": "^13.4.7",
"next-intl": "^3.0.0-beta.7",
@@ -28,10 +30,12 @@
"react-icons": "^4.10.1",
"react-leaflet": "^4.2.1",
"sharp": "^0.32.1",
+ "tailwind-merge": "^1.13.2",
"tailwindcss": "3.3.2",
"typescript": "5.1.6"
},
"devDependencies": {
+ "@swc-jotai/react-refresh": "^0.0.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/leaflet": "^1.9.3",
@@ -741,6 +745,12 @@
"integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
"dev": true
},
+ "node_modules/@swc-jotai/react-refresh": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/@swc-jotai/react-refresh/-/react-refresh-0.0.8.tgz",
+ "integrity": "sha512-fHMenTg1jeETEShCh/wlDxRpR6DZAXIuDuFIB9fZ4yf+JfrWzQkPIvPN3E0efSpOham9ucRspS0uI82PxBbCjg==",
+ "dev": true
+ },
"node_modules/@swc/helpers": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz",
@@ -4423,6 +4433,22 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jotai": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.2.1.tgz",
+ "integrity": "sha512-Gz4tpbRQy9OiFgBwF9F7TieDn0UTE3C0IFSDuxHjOIvgn2tACH30UKz6p/wIlfoZROXSTCIxEvYEa7Y25WM+8g==",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4558,6 +4584,11 @@
"resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.1.tgz",
"integrity": "sha512-vDBFdlUAwjSEGep9ih8kfJilf6yN8V9zTbF5NC/1ZwLeGko3RUQepspPnGCRMFV51dY3Lb3hziboicrFz+rxQA=="
},
+ "node_modules/leaflet.smooth_marker_bouncing": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/leaflet.smooth_marker_bouncing/-/leaflet.smooth_marker_bouncing-3.0.3.tgz",
+ "integrity": "sha512-Y+1MJ1nX0vy/NjvzW4Kq2gE3Pnpz3fgP3dZJQNMQW90bFQ8d2TjXrqazP5oWKNUWjvrVVzfMv/FrB7vUFmsLDA=="
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6649,6 +6680,15 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.13.2.tgz",
+ "integrity": "sha512-R2/nULkdg1VR/EL4RXg4dEohdoxNUJGLMnWIQnPKL+O9Twu7Cn3Rxi4dlXkDzZrEGtR+G+psSXFouWlpTyLhCQ==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
diff --git a/package.json b/package.json
index 9c29655..0b05154 100644
--- a/package.json
+++ b/package.json
@@ -21,8 +21,10 @@
"autoprefixer": "10.4.14",
"eslint": "8.43.0",
"eslint-config-next": "^13.4.7",
+ "jotai": "^2.2.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1",
+ "leaflet.smooth_marker_bouncing": "^3.0.3",
"mongodb": "^5.6.0",
"next": "^13.4.7",
"next-intl": "^3.0.0-beta.7",
@@ -33,10 +35,12 @@
"react-icons": "^4.10.1",
"react-leaflet": "^4.2.1",
"sharp": "^0.32.1",
+ "tailwind-merge": "^1.13.2",
"tailwindcss": "3.3.2",
"typescript": "5.1.6"
},
"devDependencies": {
+ "@swc-jotai/react-refresh": "^0.0.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@types/leaflet": "^1.9.3",
diff --git a/types.ts b/types.ts
index 573d2f3..a7c14c5 100644
--- a/types.ts
+++ b/types.ts
@@ -9,8 +9,4 @@ export interface Tournament {
coordinates: [number, number];
}
-export interface TournamentDataProps {
- tournamentData: Tournament[];
-}
-
export type ScrollableElement = Window | HTMLElement;
diff --git a/utils/atomWithDebounce.ts b/utils/atomWithDebounce.ts
new file mode 100644
index 0000000..fbf076a
--- /dev/null
+++ b/utils/atomWithDebounce.ts
@@ -0,0 +1,66 @@
+import { atom, SetStateAction } from "jotai";
+
+export default function atomWithDebounce(
+ initialValue: T,
+ delayMilliseconds = 500,
+ shouldDebounceOnReset = false
+) {
+ const prevTimeoutAtom = atom | 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) => {
+ 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();
+
+ if (!shouldDebounceOnReset && nextValue === initialValue) {
+ onDebounceEnd();
+ return;
+ }
+
+ const nextTimeoutId = setTimeout(() => {
+ onDebounceEnd();
+ }, 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, _arg) => {
+ clearTimeout(get(prevTimeoutAtom));
+ set(isDebouncingAtom, false);
+ });
+
+ return {
+ currentValueAtom: atom((get) => get(_currentValueAtom)),
+ isDebouncingAtom,
+ clearTimeoutAtom,
+ debouncedValueAtom
+ };
+}
diff --git a/utils/dbDateOrdering.ts b/utils/dbDateOrdering.ts
deleted file mode 100644
index 3a6ed06..0000000
--- a/utils/dbDateOrdering.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Db } from "mongodb";
-
-/**
- * Converts date from string into a date to allow ordering
- */
-export const dateOrderingFrance = async (db: Db) => {
- return await db
- .collection("tournaments")
- .aggregate([
- {
- $addFields: {
- dateParts: {
- $dateFromString: {
- dateString: "$date",
- format: "%d/%m/%Y",
- },
- },
- },
- },
- {
- $sort: {
- dateParts: 1,
- },
- },
- {
- $unset: "dateParts",
- },
- ])
- .toArray();
-};
diff --git a/utils/layerGroups.tsx b/utils/layerGroups.tsx
deleted file mode 100644
index cc115d7..0000000
--- a/utils/layerGroups.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { TournamentDataProps, Tournament } from "@/types";
-import L, { LatLngLiteral } from "leaflet";
-import {
- LayerGroup,
- LayersControl,
- Marker,
- Popup,
- MarkerProps,
-} from "react-leaflet";
-import { useTranslations } from "next-intl";
-
-const coordinateRandomisation = (lat: number, lng: number): LatLngLiteral => {
- const randomisation = () => Math.random() * (-0.01 - 0.01) + 0.01;
- return {
- lat: lat + randomisation(),
- lng: lng + randomisation(),
- };
-};
-
-type TournamentMarkerProps = {
- tournament: Tournament;
- colour: string;
-} & Omit;
-
-export const TournamentMarker = ({
- tournament,
- colour,
- ...markerProps
-}: TournamentMarkerProps) => {
- const t = useTranslations("Tournaments");
-
- const iconOptions = new L.Icon({
- iconUrl: `/images/leaflet/marker-icon-2x-${colour}.png`,
- shadowUrl: "/images/leaflet/marker-shadow.png",
- iconSize: [25, 41],
- iconAnchor: [12, 41],
- popupAnchor: [1, -34],
- shadowSize: [41, 41],
- });
-
- return (
-
-
-
- {tournament.date}
-
-
- {tournament.tournament}
-
-
- {t("approx")}
-
-
- );
-};
-
-export const createLayerGroups = (
- timeControl: string,
- colour: string,
- { tournamentData }: TournamentDataProps
-) => {
- const filteredTournaments = tournamentData.filter(
- (t) => t.time_control === timeControl
- );
-
- const layerGroup = filteredTournaments.map((tournament) => {
- return (
-
- );
- });
-
- return (
-
- {layerGroup}
-
- );
-};