From ed68a9280bcbca731680701ff84d86c7e9b37d88 Mon Sep 17 00:00:00 2001 From: Timothy Armes Date: Mon, 9 Oct 2023 19:06:24 +0200 Subject: [PATCH] Clubs page --- app/[locale]/clubs/ClubMap.tsx | 36 ++++++ app/[locale]/clubs/ClubMarker.tsx | 113 +++++++++++++++++ app/[locale]/clubs/ClubTable.tsx | 148 ++++++++++++++++++++++ app/[locale]/clubs/ClubsDisplay.tsx | 38 ++++++ app/[locale]/clubs/page.tsx | 39 ++++++ app/[locale]/components/HamburgerMenu.tsx | 10 ++ app/[locale]/components/Navbar.tsx | 1 + app/atoms.ts | 22 ++++ messages/en.json | 15 +++ messages/fr.json | 15 +++ types.ts | 22 +++- 11 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 app/[locale]/clubs/ClubMap.tsx create mode 100644 app/[locale]/clubs/ClubMarker.tsx create mode 100644 app/[locale]/clubs/ClubTable.tsx create mode 100644 app/[locale]/clubs/ClubsDisplay.tsx create mode 100644 app/[locale]/clubs/page.tsx diff --git a/app/[locale]/clubs/ClubMap.tsx b/app/[locale]/clubs/ClubMap.tsx new file mode 100644 index 0000000..79aa364 --- /dev/null +++ b/app/[locale]/clubs/ClubMap.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback, useMemo } from "react"; + +import { useAtomValue } from "jotai"; +import L, { LatLngLiteral } from "leaflet"; +import "leaflet-defaulticon-compatibility"; +import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; +import "leaflet.smooth_marker_bouncing"; +import "leaflet/dist/leaflet.css"; +import { countBy, groupBy } from "lodash"; + +import { clubsAtom } from "@/app/atoms"; +import { Map, MapMarker } from "@/components/Map"; + +import { ClubMarker } from "./ClubMarker"; + +const ClubMap = () => { + const clubs = useAtomValue(clubsAtom); + + const markers: MapMarker[] = useMemo( + () => + clubs.map((club) => { + return { + markerId: club.id, + tableIds: [club.id], + component: , + }; + }), + [clubs], + ); + + return ; +}; + +export default ClubMap; diff --git a/app/[locale]/clubs/ClubMarker.tsx b/app/[locale]/clubs/ClubMarker.tsx new file mode 100644 index 0000000..1eb70b7 --- /dev/null +++ b/app/[locale]/clubs/ClubMarker.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; + +import { useSetAtom } from "jotai"; +import L from "leaflet"; +import { Marker, MarkerProps, Popup } from "react-leaflet"; + +import { debouncedHoveredMapIdAtom } from "@/app/atoms"; +import { TimeControlColours } from "@/app/constants"; +import type { MarkerRef } from "@/components/Map"; +import type { BouncingMarker } from "@/leafletTypes"; +import { Club } from "@/types"; + +type ClubMarkerProps = { + club: Club; +} & Omit; + +export const ClubMarker = forwardRef( + ({ club, ...markerProps }, ref) => { + const markerRef = useRef | null>(null); + + useImperativeHandle( + ref, + () => ({ + getMarker: () => markerRef.current!, + bounce: () => { + const bouncingMarker = markerRef.current as BouncingMarker; + bouncingMarker.setBouncingOptions({ contractHeight: 0 }); + bouncingMarker.bounce(); + }, + }), + [], + ); + + const { id, latLng } = club; + + const setHoveredMapId = useSetAtom(debouncedHoveredMapIdAtom); + + const iconOptions = useMemo( + () => + new L.DivIcon({ + html: ` + + + + + `, + className: "", + iconSize: [24, 40], + iconAnchor: [12, 40], + popupAnchor: [1, -40], + }), + [], + ); + + return ( + setHoveredMapId(id), + mouseout: () => setHoveredMapId(null), + }} + {...markerProps} + > + +
+
+
{club.name}
+ {club.address &&
{club.address}
} + {club.website && ( + + )} + {club.url && ( + + )} + {club.email && ( + + )} +
+
+
+
+ ); + }, +); + +ClubMarker.displayName = "ClubMarker"; diff --git a/app/[locale]/clubs/ClubTable.tsx b/app/[locale]/clubs/ClubTable.tsx new file mode 100644 index 0000000..de17c2c --- /dev/null +++ b/app/[locale]/clubs/ClubTable.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect } from "react"; + +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useTranslations } from "next-intl"; +import { FaExternalLinkAlt } from "react-icons/fa"; +import { twMerge } from "tailwind-merge"; + +import { + debouncedHoveredListIdAtom, + debouncedHoveredMapIdAtom, + filteredClubsListAtom, + hoveredMapIdAtom, + syncVisibleAtom, +} from "@/app/atoms"; +import ScrollToTopButton from "@/components/ScrollToTopButton"; +import SearchBar from "@/components/SearchBar"; +import { useBreakpoint } from "@/hooks/tailwind"; + +const ClubTable = () => { + const t = useTranslations("Clubs"); + const at = useTranslations("App"); + + const filteredClubs = useAtomValue(filteredClubsListAtom); + const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom); + const hoveredMapId = useAtomValue(hoveredMapIdAtom); + const debouncedHoveredMapId = useAtomValue(debouncedHoveredMapIdAtom); + const setHoveredListId = useSetAtom(debouncedHoveredListIdAtom); + + const isLg = useBreakpoint("lg"); + + useEffect(() => { + if (!isLg || debouncedHoveredMapId === null) return; + const clubRow = document.querySelector( + `[data-group-id="${debouncedHoveredMapId}"]`, + ); + + clubRow?.scrollIntoView({ behavior: "smooth" }); + }, [debouncedHoveredMapId, isLg]); + + return ( +
+
+ + +
+ +
+
+ + + +
+ + + + + + + + + + + {filteredClubs.length === 0 ? ( + + + + ) : ( + filteredClubs.map((club) => ( + setHoveredListId(club.id)} + onMouseLeave={() => setHoveredListId(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", + hoveredMapId === club.id && "bg-gray-200 dark:bg-gray-900", + )} + > + + + + + )) + )} + +
+ {t("name")} + + {t("contact")} + + {t("ffe")} +
+ {t("noneFound")} +
+ {club.name} + + {club.address &&
{club.address}
} + {club.website && ( + + )} + {club.email && ( + + )} +
+
+ + + +
+
+
+
+ ); +}; + +export default ClubTable; diff --git a/app/[locale]/clubs/ClubsDisplay.tsx b/app/[locale]/clubs/ClubsDisplay.tsx new file mode 100644 index 0000000..3df8305 --- /dev/null +++ b/app/[locale]/clubs/ClubsDisplay.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useHydrateAtoms } from "jotai/utils"; +import dynamic from "next/dynamic"; + +import { clubsAtom } from "@/app/atoms"; +import LoadingMap from "@/components/LoadingMap"; +import { Club } from "@/types"; + +import ClubTable from "./ClubTable"; + +type ClubsDisplayProps = { + clubs: Club[]; +}; + +/** + * Imports the club map component, ensuring CSR only. + * @remarks SSR is not supported by react-leaflet + */ +const ClubMap = dynamic(() => import("./ClubMap"), { + ssr: false, + loading: LoadingMap, +}); + +export default function ClubsDisplay({ clubs }: ClubsDisplayProps) { + useHydrateAtoms([[clubsAtom, clubs]]); + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/app/[locale]/clubs/page.tsx b/app/[locale]/clubs/page.tsx new file mode 100644 index 0000000..15f606b --- /dev/null +++ b/app/[locale]/clubs/page.tsx @@ -0,0 +1,39 @@ +import { groupBy } from "lodash"; + +import clientPromise from "@/lib/mongodb"; +import { Club, ClubData } from "@/types"; +import { errorLog } from "@/utils/logger"; + +import ClubsDisplay from "./ClubsDisplay"; + +export const revalidate = 3600; // Revalidate cache every 6 hours + +const getClubs = async () => { + try { + const client = await clientPromise; + const db = client.db("tournamentsFranceDB"); + const data = await db.collection("clubs").find({}).toArray(); + + return data + .filter((c) => !!c.coordinates) + .map((club) => { + return { + id: club._id.toString(), + name: club.name, + url: club.url, + address: club.address, + email: club.email, + website: club.website, + latLng: { lat: club.coordinates[0], lng: club.coordinates[1] }, + }; + }); + } catch (error) { + errorLog(error); + throw new Error("Error fetching club data"); + } +}; + +export default async function Clubs() { + const clubs = await getClubs(); + return ; +} diff --git a/app/[locale]/components/HamburgerMenu.tsx b/app/[locale]/components/HamburgerMenu.tsx index 307ffe3..e25bfdc 100644 --- a/app/[locale]/components/HamburgerMenu.tsx +++ b/app/[locale]/components/HamburgerMenu.tsx @@ -70,6 +70,16 @@ const HamburgerMenu = ({ {t("tournaments")} + +
  • + + {t("clubs")} + +
  • +
  • { mapBounds.contains(tournament.latLng), ); }); + +export const filteredClubsListAtom = atom((get) => { + const clubs = get(clubsAtom); + const mapBounds = get(mapBoundsAtom); + const syncVisible = get(syncVisibleAtom); + const searchString = get(searchStringAtom).trim(); + + // When searching, we search all the tournament, regardless of the map display + if (searchString !== "") { + return clubs.filter( + (club) => + normalizedContains(club.name, searchString) || + (club.address && normalizedContains(club.address, searchString)), + ); + } + + // If we not syncing to the map, return all clubs + if (mapBounds === null || !syncVisible) return clubs; + + // Filter by those in the current map bounds + return clubs.filter((club) => mapBounds.contains(club.latLng)); +}); diff --git a/messages/en.json b/messages/en.json index 0bb34ff..5724460 100644 --- a/messages/en.json +++ b/messages/en.json @@ -19,6 +19,7 @@ "Nav": { "title": "Échecs France", "tournaments": "Tournaments", + "clubs": "Clubs", "elo": "Elo Calculator" }, @@ -48,6 +49,7 @@ "noneFound": "No tournaments found", "date": "Date", "town": "Town", + "ffe": "FFE", "tournament": "Tournament", "timeControl": "Time Control", "approx": "Geo-location is approximative", @@ -55,6 +57,19 @@ "clearButton": "Clear" }, + "Clubs": { + "loading": "Loading...", + "searchLabel": "Search", + "searchPlaceholder": "Search", + "syncWithMapCheckbox": "Clubs visible on the map", + "noneFound": "No clubs found", + "name": "Name", + "ffe": "FFE", + "contact": "Contact info", + "approx": "Geo-location is approximative", + "clearButton": "Effacer" + }, + "About": { "title": "Who are we?", "p1": "This project came to life in early 2022 with the aim of providing a visualization of chess tournaments on a map in France. Having moved to France in 2019, I was not familiar with the geography of the country and wanted to know which tournaments were taking place near my location.", diff --git a/messages/fr.json b/messages/fr.json index 84c8dc9..b0f9914 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -19,6 +19,7 @@ "Nav": { "title": "Échecs France", "tournaments": "Tournois", + "clubs": "Clubs", "elo": "Calculette Elo" }, @@ -48,6 +49,7 @@ "noneFound": "Pas de tournois trouvé", "date": "Date", "town": "Ville", + "ffe": "FFE", "tournament": "Tournois", "timeControl": "Cadence", "approx": "Géolocalisation approximative", @@ -55,6 +57,19 @@ "clearButton": "Effacer" }, + "Clubs": { + "loading": "Téléchargement...", + "searchLabel": "Rechercher", + "searchPlaceholder": "Rechercher", + "syncWithMapCheckbox": "Clubs visibles sur la carte", + "noneFound": "Pas de club trouvé", + "name": "Name", + "ffe": "FFE", + "contact": "Contact", + "approx": "Géolocalisation approximative", + "clearButton": "Effacer" + }, + "About": { "title": "Qui Sommes-Nous?", "p1": "Ce projet a vu le jour début 2022 afin de permettre une visualisation sur une carte des tournois d'échecs en France. Ayant déménagé en France en 2019, je ne connaissais alors pas la géographie française et je souhaitais savoir quels tournois avaient lieu près de chez moi.", diff --git a/types.ts b/types.ts index 7f41652..2ac22ff 100644 --- a/types.ts +++ b/types.ts @@ -16,7 +16,17 @@ export type TournamentData = { coordinates: [number, number]; entry_method: "manual" | "auto"; pending: boolean; - status: Status + status: Status; +}; + +export type ClubData = { + _id: ObjectId; + name: string; + url?: string; + address?: string; + email?: string; + website?: string; + coordinates: [number, number]; }; export enum TimeControl { @@ -41,6 +51,16 @@ export type Tournament = { status: Status; }; +export type Club = { + id: string; + name: string; + url?: string; + address?: string; + email?: string; + website?: string; + latLng: LatLngLiteral; +}; + export type ResponseMessage = { isSuccessful: boolean; message: string;