Merge pull request #61 from timothyarmes/feature/filters

Feature/filters
This commit is contained in:
Owen Rees
2023-07-05 17:54:50 +02:00
committed by GitHub
11 changed files with 174 additions and 70 deletions
+10 -5
View File
@@ -2,9 +2,10 @@ import { useMap } from "react-leaflet";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import L from "leaflet"; import L from "leaflet";
import { TimeControl } from "@/types";
const Legend = () => { const Legend = () => {
const t = useTranslations("App"); const t = useTranslations("Tournaments");
const map = useMap(); const map = useMap();
useEffect(() => { useEffect(() => {
@@ -20,16 +21,20 @@ const Legend = () => {
); );
div.innerHTML = `<ul> div.innerHTML = `<ul>
<li><span style='background:#00ac39; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t( <li><span style='background:#00ac39; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
"tcClassic" "timeControlEnum",
{ tc: TimeControl.Classic }
)}</li> )}</li>
<li><span style='background:#0086c7; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t( <li><span style='background:#0086c7; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
"tcRapid" "timeControlEnum",
{ tc: TimeControl.Rapid }
)}</li> )}</li>
<li><span style='background:#cec348; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t( <li><span style='background:#cec348; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
"tcBlitz" "timeControlEnum",
{ tc: TimeControl.Blitz }
)}</li> )}</li>
<li><span style='background:#d10c3e; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t( <li><span style='background:#d10c3e; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
"tcKO" "timeControlEnum",
{ tc: TimeControl.KO }
)}</li> )}</li>
</ul>`; </ul>`;
return div; return div;
@@ -0,0 +1,40 @@
import { useAtom } from "jotai";
import { classicAtom, rapidAtom, blitzAtom, oneHourKOAtom } from "@/app/atoms";
import { useTranslations } from "next-intl";
import { TimeControl } from "@/types";
const TimeControlFilters = () => {
const t = useTranslations("Tournaments");
const classic = useAtom(classicAtom);
const rapid = useAtom(rapidAtom);
const blitz = useAtom(blitzAtom);
const oneHourKO = useAtom(oneHourKOAtom);
const checkboxes = [
{ title: t("timeControlEnum", { tc: TimeControl.Classic }), atom: classic },
{ title: t("timeControlEnum", { tc: TimeControl.Rapid }), atom: rapid },
{ title: t("timeControlEnum", { tc: TimeControl.Blitz }), atom: blitz },
{ title: t("timeControlEnum", { tc: TimeControl.KO }), atom: oneHourKO },
];
return (
<div className="flex flex-wrap gap-3">
{checkboxes.map((cb, i) => (
<div key={i} className="text-gray-900 dark:text-white">
<label>
<input
type="checkbox"
className="mr-2"
checked={cb.atom[0]}
onChange={() => cb.atom[1](!cb.atom[0])}
/>
{cb.title}
</label>
</div>
))}
</div>
);
};
export default TimeControlFilters;
+42 -23
View File
@@ -1,11 +1,10 @@
"use client"; "use client";
import { Tournament } from "@/types"; import { TimeControl, Tournament } from "@/types";
import { LatLngLiteral } from "leaflet"; import { LatLngLiteral } from "leaflet";
import { import {
MapContainer, MapContainer,
TileLayer, TileLayer,
LayersControl,
LayerGroup, LayerGroup,
useMapEvent, useMapEvent,
} from "react-leaflet"; } from "react-leaflet";
@@ -15,10 +14,14 @@ 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 { tournamentsAtom, mapBoundsAtom } from "@/app/atoms"; import {
filteredTournamentsByTimeControlAtom,
mapBoundsAtom,
} from "@/app/atoms";
import Legend from "./Legend"; import Legend from "./Legend";
import { TournamentMarker } from "./TournamentMarker"; import { TournamentMarker } from "./TournamentMarker";
import TimeControlFilters from "./TimeControlFilters";
const MapEvents = () => { const MapEvents = () => {
const setMapBounds = useSetAtom(mapBoundsAtom); const setMapBounds = useSetAtom(mapBoundsAtom);
@@ -30,16 +33,17 @@ const MapEvents = () => {
}; };
export default function TournamentMap() { export default function TournamentMap() {
const tournaments = useAtomValue(tournamentsAtom); const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
const setMapBounds = useSetAtom(mapBoundsAtom);
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 }; const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
const createLayerGroups = ( const createLayerGroups = (
timeControl: string, timeControl: TimeControl,
colour: string, colour: string,
tournaments: Tournament[] tournaments: Tournament[]
) => { ) => {
const filteredTournaments = tournaments.filter( const filteredTournaments = tournaments.filter(
(t) => t.time_control === timeControl (t) => t.timeControl === timeControl
); );
const layerGroup = filteredTournaments.map((tournament) => { const layerGroup = filteredTournaments.map((tournament) => {
@@ -52,30 +56,46 @@ export default function TournamentMap() {
); );
}); });
return ( return <LayerGroup>{layerGroup}</LayerGroup>;
<LayersControl.Overlay checked name={timeControl}>
<LayerGroup>{layerGroup}</LayerGroup>
</LayersControl.Overlay>
);
}; };
const classicalMarkers = createLayerGroups( const classicalMarkers = createLayerGroups(
"Cadence Lente", TimeControl.Classic,
"green", "green",
tournaments tournaments
); );
const rapidMarkers = createLayerGroups("Rapide", "blue", tournaments); const rapidMarkers = createLayerGroups(
const blitzMarkers = createLayerGroups("Blitz", "yellow", tournaments); TimeControl.Rapid,
const otherMarkers = createLayerGroups("1h KO", "red", tournaments); "blue",
tournaments
);
const blitzMarkers = createLayerGroups(
TimeControl.Blitz,
"yellow",
tournaments
);
const otherMarkers = createLayerGroups(TimeControl.KO, "red", tournaments);
return ( return (
<section id="tournament-map" className="grid h-[calc(100vh-144px)]"> <section
id="tournament-map"
className="flex h-[calc(100vh-144px)] flex-col"
>
<div className="p-3 lg:hidden">
<TimeControlFilters />
</div>
<MapContainer <MapContainer
center={center} center={center}
zoom={5} zoom={5}
scrollWheelZoom={false} scrollWheelZoom={false}
style={{ style={{
height: "100%", flexGrow: 1,
}}
ref={(map) => {
if (map) {
setMapBounds(map.getBounds());
}
}} }}
> >
<MapEvents /> <MapEvents />
@@ -84,12 +104,11 @@ export default function TournamentMap() {
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
<Legend /> <Legend />
<LayersControl>
{classicalMarkers} {classicalMarkers}
{rapidMarkers} {rapidMarkers}
{blitzMarkers} {blitzMarkers}
{otherMarkers} {otherMarkers}
</LayersControl>
</MapContainer> </MapContainer>
</section> </section>
); );
+4 -9
View File
@@ -13,11 +13,11 @@ import {
debouncedHoveredMapTournamentIdAtom, debouncedHoveredMapTournamentIdAtom,
} from "@/app/atoms"; } from "@/app/atoms";
const coordinateRandomisation = (lat: number, lng: number): LatLngLiteral => { const coordinateRandomisation = (latLng: LatLngLiteral): LatLngLiteral => {
const randomisation = () => Math.random() * (-0.01 - 0.01) + 0.01; const randomisation = () => Math.random() * (-0.01 - 0.01) + 0.01;
return { return {
lat: lat + randomisation(), lat: latLng.lat + randomisation(),
lng: lng + randomisation(), lng: latLng.lng + randomisation(),
}; };
}; };
@@ -33,12 +33,7 @@ export const TournamentMarker = ({
}: TournamentMarkerProps) => { }: TournamentMarkerProps) => {
const t = useTranslations("Tournaments"); const t = useTranslations("Tournaments");
const markerRef = useRef<L.Marker<any> | null>(null); const markerRef = useRef<L.Marker<any> | null>(null);
const position = useRef( const position = useRef(coordinateRandomisation(tournament.latLng));
coordinateRandomisation(
tournament.coordinates[0],
tournament.coordinates[1]
)
);
const hoveredListTournamentId = useAtomValue( const hoveredListTournamentId = useAtomValue(
debouncedHoveredListTournamentIdAtom debouncedHoveredListTournamentIdAtom
+11 -5
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAtomValue, useSetAtom, useAtom } from "jotai"; import { useAtomValue, useSetAtom, useAtom } from "jotai";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { import {
filteredTournamentsAtom, filteredTournamentsListAtom,
syncVisibleAtom, syncVisibleAtom,
hoveredMapTournamentIdAtom, hoveredMapTournamentIdAtom,
debouncedHoveredMapTournamentIdAtom, debouncedHoveredMapTournamentIdAtom,
@@ -13,13 +14,13 @@ import {
} from "@/app/atoms"; } from "@/app/atoms";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import TimeControlFilters from "./TimeControlFilters";
import ScrollToTopButton from "./ScrollToTopButton"; import ScrollToTopButton from "./ScrollToTopButton";
import { useEffect } from "react";
export default function TournamentTable() { export default function TournamentTable() {
const t = useTranslations("Tournaments"); const t = useTranslations("Tournaments");
const filteredTournaments = useAtomValue(filteredTournamentsAtom); const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom); const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
const hoveredMapTournamentId = useAtomValue(hoveredMapTournamentIdAtom); const hoveredMapTournamentId = useAtomValue(hoveredMapTournamentIdAtom);
const debouncedHoveredMapTournamentId = useAtomValue( const debouncedHoveredMapTournamentId = useAtomValue(
@@ -50,12 +51,17 @@ export default function TournamentTable() {
<label> <label>
<input <input
type="checkbox" type="checkbox"
className="mr-2"
checked={syncVisible} checked={syncVisible}
onChange={() => setSyncVisible(!syncVisible)} onChange={() => setSyncVisible(!syncVisible)}
/>{" "} />
{t("syncWithMapCheckbox")} {t("syncWithMapCheckbox")}
</label> </label>
</div> </div>
<div className="hidden lg:block">
<TimeControlFilters />
</div>
</div> </div>
<ScrollToTopButton /> <ScrollToTopButton />
@@ -117,7 +123,7 @@ export default function TournamentTable() {
</td> </td>
<td className="p-3"> <td className="p-3">
<a href={tournament.url} target="_blank"> <a href={tournament.url} target="_blank">
{tournament.time_control} {tournament.timeControl}
</a> </a>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -38,7 +38,7 @@ export default async function TournamentsDisplay({
return ( return (
<main className="relative grid h-full w-full lg:grid-cols-2"> <main className="relative grid h-full w-full lg:grid-cols-2">
<div className=""> <div>
<TournamentMap /> <TournamentMap />
</div> </div>
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto"> <div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
+24 -4
View File
@@ -1,19 +1,38 @@
import clientPromise from "@/lib/mongodb"; import clientPromise from "@/lib/mongodb";
import { errorLog } from "@/utils/logger"; import { errorLog } from "@/utils/logger";
import { Tournament } from "@/types"; import { Tournament, TimeControl } from "@/types";
import TournamentsDisplay from "./TournamentsDisplay"; import TournamentsDisplay from "./TournamentsDisplay";
import { ObjectId } from "mongodb";
export const revalidate = 3600; // Revalidate cache every 6 hours export const revalidate = 3600; // Revalidate cache every 6 hours
export interface TournamentData {
_id: ObjectId;
town: string;
department: string;
tournament: string;
url: string;
time_control: "Cadence Lente" | "Rapide" | "Blitz" | "1h KO";
date: string;
coordinates: [number, number];
}
const tcMap: Record<TournamentData["time_control"], TimeControl> = {
"Cadence Lente": TimeControl.Classic,
Rapide: TimeControl.Rapid,
Blitz: TimeControl.Blitz,
"1h KO": TimeControl.KO,
};
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 db const data = await db
.collection("tournaments") .collection("tournaments")
.aggregate<Tournament>([ .aggregate<TournamentData>([
{ {
$addFields: { $addFields: {
dateParts: { dateParts: {
@@ -35,10 +54,11 @@ const getTournaments = async () => {
]) ])
.toArray(); .toArray();
// We map the ObjectId to a string so that it can be serialized and sent to a client component return data.map<Tournament>((t) => ({
return data.map((t) => ({
...t, ...t,
_id: t._id.toString(), _id: t._id.toString(),
timeControl: tcMap[t.time_control],
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
})); }));
} catch (error) { } catch (error) {
errorLog(error); errorLog(error);
+27 -5
View File
@@ -1,14 +1,19 @@
import { Tournament } from '@/types'; import { TimeControl, Tournament } from '@/types';
import { atom } from 'jotai'; import { atom } from 'jotai';
import { LatLngBounds } from 'leaflet'; import { LatLngBounds } from 'leaflet';
import atomWithDebounce from "@/utils/atomWithDebounce"; import atomWithDebounce from "@/utils/atomWithDebounce";
export const tournamentsAtom = atom<Tournament[]>([]); export const tournamentsAtom = atom<Tournament[]>([]);
export const searchStringAtom = atom('');
export const mapBoundsAtom = atom<LatLngBounds | null>(null); export const mapBoundsAtom = atom<LatLngBounds | null>(null);
export const syncVisibleAtom = atom(true); export const syncVisibleAtom = atom(true);
export const searchStringAtom = atom('');
export const classicAtom = atom(true);
export const rapidAtom = atom(true);
export const blitzAtom = atom(true);
export const oneHourKOAtom = atom(true);
export const { export const {
currentValueAtom: hoveredMapTournamentIdAtom, currentValueAtom: hoveredMapTournamentIdAtom,
debouncedValueAtom: debouncedHoveredMapTournamentIdAtom, debouncedValueAtom: debouncedHoveredMapTournamentIdAtom,
@@ -18,12 +23,29 @@ export const {
debouncedValueAtom: debouncedHoveredListTournamentIdAtom, debouncedValueAtom: debouncedHoveredListTournamentIdAtom,
} = atomWithDebounce<string | null>(null); } = atomWithDebounce<string | null>(null);
export const filteredTournamentsAtom = atom((get) => { export const filteredTournamentsByTimeControlAtom = atom((get) => {
const tournaments = get(tournamentsAtom); const tournaments = get(tournamentsAtom);
const searchString = get(searchStringAtom).trim();
const classic = get(classicAtom);
const rapid = get(rapidAtom);
const blitz = get(blitzAtom);
const oneHourKO = get(oneHourKOAtom);
return tournaments.filter(tournament =>
(tournament.timeControl === TimeControl.Classic && classic) ||
(tournament.timeControl === TimeControl.Rapid && rapid) ||
(tournament.timeControl === TimeControl.Blitz && blitz) ||
(tournament.timeControl === TimeControl.KO && oneHourKO));
});
export const filteredTournamentsListAtom = atom((get) => {
const tournaments = get(filteredTournamentsByTimeControlAtom);
const mapBounds = get(mapBoundsAtom); const mapBounds = get(mapBoundsAtom);
const syncVisible = get(syncVisibleAtom); const syncVisible = get(syncVisibleAtom);
const searchString = get(searchStringAtom).trim();
// When searching, we search all the tournament, regardless of the map display // When searching, we search all the tournament, regardless of the map display
if (searchString !== '') if (searchString !== '')
return tournaments.filter((t) => t.town.includes(searchString.toUpperCase())) return tournaments.filter((t) => t.town.includes(searchString.toUpperCase()))
@@ -32,5 +54,5 @@ export const filteredTournamentsAtom = atom((get) => {
if (mapBounds === null || !syncVisible) return tournaments; if (mapBounds === null || !syncVisible) return tournaments;
// Filter by those in the current map bounds // Filter by those in the current map bounds
return tournaments.filter(tournament => mapBounds.contains({ lat: tournament.coordinates[0], lng: tournament.coordinates[1]})) return tournaments.filter(tournament => mapBounds.contains(tournament.latLng))
}) })
+2 -8
View File
@@ -17,13 +17,6 @@
"contactAria": "Contact Us" "contactAria": "Contact Us"
}, },
"App": {
"tcClassic": "Classic",
"tcRapid": "Rapid",
"tcBlitz": "Blitz",
"tcKO": "1h KO"
},
"Home": { "Home": {
"title": "France Echecs", "title": "France Echecs",
"purpose": "Find chess tournaments, in France, on a map", "purpose": "Find chess tournaments, in France, on a map",
@@ -41,7 +34,8 @@
"town": "Town", "town": "Town",
"tournament": "Tournament", "tournament": "Tournament",
"timeControl": "Time Control", "timeControl": "Time Control",
"approx": "Géo-localisation is approximative" "approx": "Géo-localisation is approximative",
"timeControlEnum": "{tc, select, Classic {Classic} Rapid {Rapid} Blitz {Blitz} KO {1h KO} other {{tc}}}"
}, },
"About": { "About": {
+2 -8
View File
@@ -17,13 +17,6 @@
"contactAria": "Contactez-nous" "contactAria": "Contactez-nous"
}, },
"App": {
"tcClassic": "Cadence Lente",
"tcRapid": "Rapide",
"tcBlitz": "Blitz",
"tcKO": "1h KO"
},
"Home": { "Home": {
"title": "Échecs France", "title": "Échecs France",
"purpose": "Trouvez Vos Tournois d'Échecs en France Sur Une Carte", "purpose": "Trouvez Vos Tournois d'Échecs en France Sur Une Carte",
@@ -41,7 +34,8 @@
"town": "Ville", "town": "Ville",
"tournament": "Tournois", "tournament": "Tournois",
"timeControl": "Cadence", "timeControl": "Cadence",
"approx": "Géolocalisation approximative" "approx": "Géolocalisation approximative",
"timeControlEnum": "{tc, select, Classic {Cadence Lente} Rapid {Rapide} Blitz {Blitz} KO {1h KO} other {{tc}}}"
}, },
"About": { "About": {
+11 -2
View File
@@ -1,12 +1,21 @@
import { LatLngLiteral } from "leaflet";
export enum TimeControl {
Classic = "Classic",
Rapid = "Rapid",
Blitz = "Blitz",
KO = "KO",
};
export interface Tournament { export interface Tournament {
_id: string; _id: string;
town: string; town: string;
department: string; department: string;
tournament: string; tournament: string;
url: string; url: string;
time_control: string; timeControl: TimeControl;
date: string; date: string;
coordinates: [number, number]; latLng: LatLngLiteral;
} }
export type ScrollableElement = Window | HTMLElement; export type ScrollableElement = Window | HTMLElement;