mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-22 20:16:57 +00:00
* Mutual Highlighting (#41) * Tidy up * Delete unused API route
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"jsc": {
|
||||||
|
"experimental": {
|
||||||
|
"plugins": [["@swc-jotai/react-refresh", {}]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next-intl/link";
|
import Link from "next-intl/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import ThemeSwitcher from "./ThemeSwitcher";
|
|
||||||
import Hamburger from "./Hamburger";
|
import Hamburger from "./Hamburger";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import "@/app/globals.css";
|
import "@/app/globals.css";
|
||||||
|
import Providers from "./providers";
|
||||||
import TestableLayout from "./components/TestableLayout";
|
import TestableLayout from "./components/TestableLayout";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -50,9 +51,11 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
|
<Providers>
|
||||||
<TestableLayout locale={locale} messages={messages}>
|
<TestableLayout locale={locale} messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
</TestableLayout>
|
</TestableLayout>
|
||||||
|
</Providers>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Provider } from "jotai";
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Provider>{children}</Provider>;
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import { Dispatch, SetStateAction } from "react";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
type SearchBarProps = {
|
import { searchStringAtom } from "@/app/atoms";
|
||||||
tournamentFilter: string;
|
|
||||||
setTournamentFilter: Dispatch<SetStateAction<string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchBar = ({
|
const SearchBar = () => {
|
||||||
tournamentFilter,
|
|
||||||
setTournamentFilter,
|
|
||||||
}: SearchBarProps) => {
|
|
||||||
const t = useTranslations("Tournaments");
|
const t = useTranslations("Tournaments");
|
||||||
|
const [searchString, setSearchString] = useAtom(searchStringAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-3 dark:bg-gray-800">
|
<div className="bg-white p-3 dark:bg-gray-800">
|
||||||
@@ -37,8 +32,8 @@ const SearchBar = ({
|
|||||||
id="table-search"
|
id="table-search"
|
||||||
className="block w-80 rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-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"
|
className="block w-80 rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-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")}
|
placeholder={t("searchPlaceholder")}
|
||||||
value={tournamentFilter}
|
value={searchString}
|
||||||
onChange={(e) => setTournamentFilter(e.target.value)}
|
onChange={(e) => setSearchString(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,62 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TournamentDataProps } from "@/types";
|
import { Tournament } from "@/types";
|
||||||
import { LatLngLiteral } from "leaflet";
|
import { LatLngLiteral } from "leaflet";
|
||||||
|
import {
|
||||||
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
LayersControl,
|
||||||
|
LayerGroup,
|
||||||
|
} from "react-leaflet";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||||
import "leaflet-defaulticon-compatibility";
|
import "leaflet-defaulticon-compatibility";
|
||||||
|
|
||||||
import { MapContainer, TileLayer, LayersControl } from "react-leaflet";
|
import { tournamentsAtom } from "@/app/atoms";
|
||||||
|
|
||||||
import { createLayerGroups } from "@/utils/layerGroups";
|
|
||||||
import Legend from "./Legend";
|
import Legend from "./Legend";
|
||||||
|
import { TournamentMarker } from "./TournamentMarker";
|
||||||
|
|
||||||
export default function TournamentMap({ tournamentData }: TournamentDataProps) {
|
export default function TournamentMap() {
|
||||||
|
const tournaments = useAtomValue(tournamentsAtom);
|
||||||
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
||||||
|
|
||||||
const classicalMarkers = createLayerGroups("Cadence Lente", "green", {
|
const createLayerGroups = (
|
||||||
tournamentData,
|
timeControl: string,
|
||||||
});
|
colour: string,
|
||||||
const rapidMarkers = createLayerGroups("Rapide", "blue", {
|
tournaments: Tournament[]
|
||||||
tournamentData,
|
) => {
|
||||||
});
|
const filteredTournaments = tournaments.filter(
|
||||||
const blitzMarkers = createLayerGroups("Blitz", "yellow", {
|
(t) => t.time_control === timeControl
|
||||||
tournamentData,
|
);
|
||||||
|
|
||||||
|
const layerGroup = filteredTournaments.map((tournament) => {
|
||||||
|
return (
|
||||||
|
<TournamentMarker
|
||||||
|
key={tournament._id}
|
||||||
|
tournament={tournament}
|
||||||
|
colour={colour}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const otherMarkers = createLayerGroups("1h KO", "red", { tournamentData });
|
return (
|
||||||
|
<LayersControl.Overlay checked name={timeControl}>
|
||||||
|
<LayerGroup>{layerGroup}</LayerGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const classicalMarkers = createLayerGroups(
|
||||||
|
"Cadence Lente",
|
||||||
|
"green",
|
||||||
|
tournaments
|
||||||
|
);
|
||||||
|
const rapidMarkers = createLayerGroups("Rapide", "blue", tournaments);
|
||||||
|
const blitzMarkers = createLayerGroups("Blitz", "yellow", tournaments);
|
||||||
|
const otherMarkers = createLayerGroups("1h KO", "red", tournaments);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="tournament-map" className="grid h-[calc(100vh-144px)]">
|
<section id="tournament-map" className="grid h-[calc(100vh-144px)]">
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Tournament } from "@/types";
|
||||||
|
import L, { LatLngLiteral } from "leaflet";
|
||||||
|
import { Marker, Popup, MarkerProps } from "react-leaflet";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import "leaflet.smooth_marker_bouncing";
|
||||||
|
|
||||||
|
import {
|
||||||
|
debouncedHoveredListTournamentIdAtom,
|
||||||
|
debouncedHoveredMapTournamentIdAtom,
|
||||||
|
} from "@/app/atoms";
|
||||||
|
|
||||||
|
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<MarkerProps, "position">;
|
||||||
|
|
||||||
|
export const TournamentMarker = ({
|
||||||
|
tournament,
|
||||||
|
colour,
|
||||||
|
...markerProps
|
||||||
|
}: TournamentMarkerProps) => {
|
||||||
|
const t = useTranslations("Tournaments");
|
||||||
|
const markerRef = useRef<L.Marker<any> | null>(null);
|
||||||
|
const position = useRef(
|
||||||
|
coordinateRandomisation(
|
||||||
|
tournament.coordinates[0],
|
||||||
|
tournament.coordinates[1]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hoveredListTournamentId = useAtomValue(
|
||||||
|
debouncedHoveredListTournamentIdAtom
|
||||||
|
);
|
||||||
|
const setHoveredMapTournamentId = useSetAtom(
|
||||||
|
debouncedHoveredMapTournamentIdAtom
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markerRef.current) return;
|
||||||
|
if (hoveredListTournamentId === tournament._id) {
|
||||||
|
// @ts-ignore (bounce comes from leaflet.smooth_marker_bouncing and isn't defined by the Typescript class)
|
||||||
|
markerRef.current.bounce();
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
markerRef.current.stopBouncing();
|
||||||
|
}
|
||||||
|
}, [hoveredListTournamentId, tournament._id]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
position={position.current}
|
||||||
|
icon={iconOptions}
|
||||||
|
eventHandlers={{
|
||||||
|
mouseover: () => setHoveredMapTournamentId(tournament._id),
|
||||||
|
mouseout: () => setHoveredMapTournamentId(null),
|
||||||
|
}}
|
||||||
|
{...markerProps}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<p>
|
||||||
|
{tournament.date}
|
||||||
|
<br />
|
||||||
|
<a href={tournament.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{tournament.tournament}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{t("approx")}
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,65 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TournamentDataProps } from "@/types";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
filteredTournamentsAtom,
|
||||||
|
hoveredMapTournamentIdAtom,
|
||||||
|
debouncedHoveredMapTournamentIdAtom,
|
||||||
|
debouncedHoveredListTournamentIdAtom,
|
||||||
|
} from "@/app/atoms";
|
||||||
|
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import ScrollToTopButton from "./ScrollToTopButton";
|
import ScrollToTopButton from "./ScrollToTopButton";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function TournamentTable({
|
export default function TournamentTable() {
|
||||||
tournamentData,
|
|
||||||
}: TournamentDataProps) {
|
|
||||||
let tableData;
|
|
||||||
const t = useTranslations("Tournaments");
|
const t = useTranslations("Tournaments");
|
||||||
const [searchQuery, setSearchQuery] = useState(""); // text from search bar
|
|
||||||
const [filteredTournamentData, setFilteredTournamentData] =
|
const filteredTournaments = useAtomValue(filteredTournamentsAtom);
|
||||||
useState(tournamentData);
|
const hoveredMapTournamentId = useAtomValue(hoveredMapTournamentIdAtom);
|
||||||
|
const debouncedHoveredMapTournamentId = useAtomValue(
|
||||||
|
debouncedHoveredMapTournamentIdAtom
|
||||||
|
);
|
||||||
|
const setHoveredListTournamentId = useSetAtom(
|
||||||
|
debouncedHoveredListTournamentIdAtom
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilteredTournamentData(
|
if (debouncedHoveredMapTournamentId === null) return;
|
||||||
tournamentData.filter((t) => t.town.includes(searchQuery.toUpperCase()))
|
|
||||||
);
|
|
||||||
}, [searchQuery, tournamentData]);
|
|
||||||
|
|
||||||
// TODO move this section into its own function
|
const tournamentRow = document.getElementById(
|
||||||
if (filteredTournamentData.length === 0) {
|
debouncedHoveredMapTournamentId
|
||||||
tableData = (
|
|
||||||
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
|
|
||||||
<td colSpan={4} className="p-3">
|
|
||||||
{t("noneFound")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
} else {
|
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
||||||
tableData = filteredTournamentData.map((data) => (
|
}, [debouncedHoveredMapTournamentId]);
|
||||||
<tr
|
|
||||||
className="bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900"
|
|
||||||
key={data._id}
|
|
||||||
>
|
|
||||||
<td className="p-3">
|
|
||||||
<a href={data.url} target="_blank">
|
|
||||||
{data.date}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<a href={data.url} target="_blank">
|
|
||||||
{data.town}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<a href={data.url} target="_blank">
|
|
||||||
{data.tournament}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
|
||||||
<a href={data.url} target="_blank">
|
|
||||||
{data.time_control}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -68,10 +43,7 @@ export default function TournamentTable({
|
|||||||
data-test="tournament-table-div"
|
data-test="tournament-table-div"
|
||||||
>
|
>
|
||||||
<div className="z-10 flex">
|
<div className="z-10 flex">
|
||||||
<SearchBar
|
<SearchBar />
|
||||||
tournamentFilter={searchQuery}
|
|
||||||
setTournamentFilter={setSearchQuery}
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<ScrollToTopButton />
|
<ScrollToTopButton />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +68,50 @@ export default function TournamentTable({
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{tableData}</tbody>
|
<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}
|
||||||
|
onMouseEnter={() => 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<a href={tournament.url} target="_blank">
|
||||||
|
{tournament.date}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<a href={tournament.url} target="_blank">
|
||||||
|
{tournament.town}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<a href={tournament.url} target="_blank">
|
||||||
|
{tournament.tournament}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<a href={tournament.url} target="_blank">
|
||||||
|
{tournament.time_control}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="grid h-screen place-items-center bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
|
||||||
|
<p>{t("loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<main className="relative grid h-full w-full lg:grid-cols-2">
|
||||||
|
<div className="">
|
||||||
|
<TournamentMap />
|
||||||
|
</div>
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
|
||||||
|
<TournamentTable />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,45 @@
|
|||||||
import clientPromise from "@/lib/mongodb";
|
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 { 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;
|
export const revalidate = 3600; // Revalidate cache every 6 hours
|
||||||
|
|
||||||
const LoadingMap = () => {
|
|
||||||
const t = useTranslations("Tournaments");
|
|
||||||
return (
|
|
||||||
<div className="grid h-screen place-items-center bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
|
|
||||||
<p>{t("loading")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getTournaments = async () => {
|
const getTournaments = async () => {
|
||||||
try {
|
try {
|
||||||
const client = await clientPromise;
|
const client = await clientPromise;
|
||||||
const db = client.db("tournamentsFranceDB");
|
const db = client.db("tournamentsFranceDB");
|
||||||
const data = await dateOrderingFrance(db);
|
const data = await db
|
||||||
return JSON.stringify(data);
|
.collection("tournaments")
|
||||||
|
.aggregate<Tournament>([
|
||||||
|
{
|
||||||
|
$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) {
|
} catch (error) {
|
||||||
errorLog(error);
|
errorLog(error);
|
||||||
throw new Error("Error fetching tournament data");
|
throw new Error("Error fetching tournament data");
|
||||||
@@ -40,16 +47,7 @@ const getTournaments = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Tournaments() {
|
export default async function Tournaments() {
|
||||||
const tournamentData = await getTournaments();
|
const tournaments = await getTournaments();
|
||||||
|
|
||||||
return (
|
return <TournamentsDisplay tournaments={tournaments} />;
|
||||||
<main className="relative grid h-full w-full lg:grid-cols-2">
|
|
||||||
<div className="">
|
|
||||||
<TournamentMap tournamentData={JSON.parse(tournamentData)} />
|
|
||||||
</div>
|
|
||||||
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
|
|
||||||
<TournamentTable tournamentData={JSON.parse(tournamentData)} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Tournament } from '@/types';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
import atomWithDebounce from "@/utils/atomWithDebounce";
|
||||||
|
|
||||||
|
export const tournamentsAtom = atom<Tournament[]>([]);
|
||||||
|
export const searchStringAtom = atom('');
|
||||||
|
|
||||||
|
export const {
|
||||||
|
currentValueAtom: hoveredMapTournamentIdAtom,
|
||||||
|
debouncedValueAtom: debouncedHoveredMapTournamentIdAtom,
|
||||||
|
} = atomWithDebounce<string | null>(null);
|
||||||
|
|
||||||
|
export const {
|
||||||
|
debouncedValueAtom: debouncedHoveredListTournamentIdAtom,
|
||||||
|
} = atomWithDebounce<string | null>(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()))
|
||||||
|
})
|
||||||
Generated
+40
@@ -16,8 +16,10 @@
|
|||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"eslint": "8.43.0",
|
"eslint": "8.43.0",
|
||||||
"eslint-config-next": "^13.4.7",
|
"eslint-config-next": "^13.4.7",
|
||||||
|
"jotai": "^2.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet-defaulticon-compatibility": "^0.1.1",
|
"leaflet-defaulticon-compatibility": "^0.1.1",
|
||||||
|
"leaflet.smooth_marker_bouncing": "^3.0.3",
|
||||||
"mongodb": "^5.6.0",
|
"mongodb": "^5.6.0",
|
||||||
"next": "^13.4.7",
|
"next": "^13.4.7",
|
||||||
"next-intl": "^3.0.0-beta.7",
|
"next-intl": "^3.0.0-beta.7",
|
||||||
@@ -28,10 +30,12 @@
|
|||||||
"react-icons": "^4.10.1",
|
"react-icons": "^4.10.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.32.1",
|
||||||
|
"tailwind-merge": "^1.13.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"typescript": "5.1.6"
|
"typescript": "5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc-jotai/react-refresh": "^0.0.8",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@types/leaflet": "^1.9.3",
|
"@types/leaflet": "^1.9.3",
|
||||||
@@ -741,6 +745,12 @@
|
|||||||
"integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
|
"integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz",
|
||||||
@@ -4423,6 +4433,22 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.1.tgz",
|
||||||
"integrity": "sha512-vDBFdlUAwjSEGep9ih8kfJilf6yN8V9zTbF5NC/1ZwLeGko3RUQepspPnGCRMFV51dY3Lb3hziboicrFz+rxQA=="
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -6649,6 +6680,15 @@
|
|||||||
"url": "https://opencollective.com/unts"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz",
|
||||||
|
|||||||
@@ -21,8 +21,10 @@
|
|||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"eslint": "8.43.0",
|
"eslint": "8.43.0",
|
||||||
"eslint-config-next": "^13.4.7",
|
"eslint-config-next": "^13.4.7",
|
||||||
|
"jotai": "^2.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet-defaulticon-compatibility": "^0.1.1",
|
"leaflet-defaulticon-compatibility": "^0.1.1",
|
||||||
|
"leaflet.smooth_marker_bouncing": "^3.0.3",
|
||||||
"mongodb": "^5.6.0",
|
"mongodb": "^5.6.0",
|
||||||
"next": "^13.4.7",
|
"next": "^13.4.7",
|
||||||
"next-intl": "^3.0.0-beta.7",
|
"next-intl": "^3.0.0-beta.7",
|
||||||
@@ -33,10 +35,12 @@
|
|||||||
"react-icons": "^4.10.1",
|
"react-icons": "^4.10.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.32.1",
|
||||||
|
"tailwind-merge": "^1.13.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"typescript": "5.1.6"
|
"typescript": "5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc-jotai/react-refresh": "^0.0.8",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@types/leaflet": "^1.9.3",
|
"@types/leaflet": "^1.9.3",
|
||||||
|
|||||||
@@ -9,8 +9,4 @@ export interface Tournament {
|
|||||||
coordinates: [number, number];
|
coordinates: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentDataProps {
|
|
||||||
tournamentData: Tournament[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScrollableElement = Window | HTMLElement;
|
export type ScrollableElement = Window | HTMLElement;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { atom, SetStateAction } from "jotai";
|
||||||
|
|
||||||
|
export default function atomWithDebounce<T>(
|
||||||
|
initialValue: T,
|
||||||
|
delayMilliseconds = 500,
|
||||||
|
shouldDebounceOnReset = false
|
||||||
|
) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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<MarkerProps, "position">;
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Marker
|
|
||||||
position={coordinateRandomisation(
|
|
||||||
tournament.coordinates[0],
|
|
||||||
tournament.coordinates[1]
|
|
||||||
)}
|
|
||||||
icon={iconOptions}
|
|
||||||
{...markerProps}
|
|
||||||
>
|
|
||||||
<Popup>
|
|
||||||
<p>
|
|
||||||
{tournament.date}
|
|
||||||
<br />
|
|
||||||
<a href={tournament.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{tournament.tournament}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{t("approx")}
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createLayerGroups = (
|
|
||||||
timeControl: string,
|
|
||||||
colour: string,
|
|
||||||
{ tournamentData }: TournamentDataProps
|
|
||||||
) => {
|
|
||||||
const filteredTournaments = tournamentData.filter(
|
|
||||||
(t) => t.time_control === timeControl
|
|
||||||
);
|
|
||||||
|
|
||||||
const layerGroup = filteredTournaments.map((tournament) => {
|
|
||||||
return (
|
|
||||||
<TournamentMarker
|
|
||||||
key={tournament._id}
|
|
||||||
tournament={tournament}
|
|
||||||
colour={colour}
|
|
||||||
riseOnHover
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LayersControl.Overlay checked name={timeControl}>
|
|
||||||
<LayerGroup>{layerGroup}</LayerGroup>
|
|
||||||
</LayersControl.Overlay>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user