Mutual Highlighting (#41) (#55)

* Mutual Highlighting (#41)

* Tidy up

* Delete unused API route
This commit is contained in:
Timothy Armes
2023-07-05 13:55:56 +02:00
committed by GitHub
parent 96fda01929
commit 1715b1646b
18 changed files with 451 additions and 272 deletions
-1
View File
@@ -1,7 +1,6 @@
import Link from "next-intl/link";
import { useTranslations } from "next-intl";
import ThemeSwitcher from "./ThemeSwitcher";
import Hamburger from "./Hamburger";
export default function Navbar() {
+6 -3
View File
@@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
import { ReactNode } from "react";
import "@/app/globals.css";
import Providers from "./providers";
import TestableLayout from "./components/TestableLayout";
const inter = Inter({ subsets: ["latin"] });
@@ -50,9 +51,11 @@ export default async function RootLayout({
return (
<html lang={locale}>
<body className={inter.className}>
<TestableLayout locale={locale} messages={messages}>
{children}
</TestableLayout>
<Providers>
<TestableLayout locale={locale} messages={messages}>
{children}
</TestableLayout>
</Providers>
<Analytics />
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { Provider } from "jotai";
export default function Providers({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
+6 -11
View File
@@ -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>
+45 -14
View File
@@ -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)]">
+93
View File
@@ -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>
);
};
+70 -55
View File
@@ -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>
);
}
+34 -36
View File
@@ -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} />;
}
-29
View File
@@ -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 });
}
}
+24
View File
@@ -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()))
})