mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
* Mutual Highlighting (#41) * Tidy up * Delete unused API route
This commit is contained in:
@@ -1,16 +1,11 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SearchBarProps = {
|
||||
tournamentFilter: string;
|
||||
setTournamentFilter: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
import { searchStringAtom } from "@/app/atoms";
|
||||
|
||||
const SearchBar = ({
|
||||
tournamentFilter,
|
||||
setTournamentFilter,
|
||||
}: SearchBarProps) => {
|
||||
const SearchBar = () => {
|
||||
const t = useTranslations("Tournaments");
|
||||
const [searchString, setSearchString] = useAtom(searchStringAtom);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-3 dark:bg-gray-800">
|
||||
@@ -37,8 +32,8 @@ const SearchBar = ({
|
||||
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"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={tournamentFilter}
|
||||
onChange={(e) => setTournamentFilter(e.target.value)}
|
||||
value={searchString}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { TournamentDataProps } from "@/types";
|
||||
import { Tournament } from "@/types";
|
||||
import { LatLngLiteral } from "leaflet";
|
||||
import {
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
LayersControl,
|
||||
LayerGroup,
|
||||
} from "react-leaflet";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||
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 { 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 classicalMarkers = createLayerGroups("Cadence Lente", "green", {
|
||||
tournamentData,
|
||||
});
|
||||
const rapidMarkers = createLayerGroups("Rapide", "blue", {
|
||||
tournamentData,
|
||||
});
|
||||
const blitzMarkers = createLayerGroups("Blitz", "yellow", {
|
||||
tournamentData,
|
||||
});
|
||||
const createLayerGroups = (
|
||||
timeControl: string,
|
||||
colour: string,
|
||||
tournaments: Tournament[]
|
||||
) => {
|
||||
const filteredTournaments = tournaments.filter(
|
||||
(t) => t.time_control === timeControl
|
||||
);
|
||||
|
||||
const otherMarkers = createLayerGroups("1h KO", "red", { tournamentData });
|
||||
const layerGroup = filteredTournaments.map((tournament) => {
|
||||
return (
|
||||
<TournamentMarker
|
||||
key={tournament._id}
|
||||
tournament={tournament}
|
||||
colour={colour}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<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";
|
||||
|
||||
import { TournamentDataProps } from "@/types";
|
||||
import { useEffect, useState } from "react";
|
||||
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 ScrollToTopButton from "./ScrollToTopButton";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TournamentTable({
|
||||
tournamentData,
|
||||
}: TournamentDataProps) {
|
||||
let tableData;
|
||||
export default function TournamentTable() {
|
||||
const t = useTranslations("Tournaments");
|
||||
const [searchQuery, setSearchQuery] = useState(""); // text from search bar
|
||||
const [filteredTournamentData, setFilteredTournamentData] =
|
||||
useState(tournamentData);
|
||||
|
||||
const filteredTournaments = useAtomValue(filteredTournamentsAtom);
|
||||
const hoveredMapTournamentId = useAtomValue(hoveredMapTournamentIdAtom);
|
||||
const debouncedHoveredMapTournamentId = useAtomValue(
|
||||
debouncedHoveredMapTournamentIdAtom
|
||||
);
|
||||
const setHoveredListTournamentId = useSetAtom(
|
||||
debouncedHoveredListTournamentIdAtom
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredTournamentData(
|
||||
tournamentData.filter((t) => t.town.includes(searchQuery.toUpperCase()))
|
||||
);
|
||||
}, [searchQuery, tournamentData]);
|
||||
if (debouncedHoveredMapTournamentId === null) return;
|
||||
|
||||
// TODO move this section into its own function
|
||||
if (filteredTournamentData.length === 0) {
|
||||
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>
|
||||
const tournamentRow = document.getElementById(
|
||||
debouncedHoveredMapTournamentId
|
||||
);
|
||||
} else {
|
||||
tableData = filteredTournamentData.map((data) => (
|
||||
<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>
|
||||
));
|
||||
}
|
||||
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [debouncedHoveredMapTournamentId]);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -68,10 +43,7 @@ export default function TournamentTable({
|
||||
data-test="tournament-table-div"
|
||||
>
|
||||
<div className="z-10 flex">
|
||||
<SearchBar
|
||||
tournamentFilter={searchQuery}
|
||||
setTournamentFilter={setSearchQuery}
|
||||
/>
|
||||
<SearchBar />
|
||||
<div>
|
||||
<ScrollToTopButton />
|
||||
</div>
|
||||
@@ -96,7 +68,50 @@ export default function TournamentTable({
|
||||
</th>
|
||||
</tr>
|
||||
</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>
|
||||
</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 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 (
|
||||
<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,
|
||||
});
|
||||
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<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) {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
return <TournamentsDisplay tournaments={tournaments} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user