Clubs page

This commit is contained in:
Timothy Armes
2023-10-09 19:06:24 +02:00
committed by Owen Rees
parent 5b2218966f
commit ed68a9280b
11 changed files with 458 additions and 1 deletions
+36
View File
@@ -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: <ClubMarker key={club.id} club={club} />,
};
}),
[clubs],
);
return <Map markers={markers} />;
};
export default ClubMap;
+113
View File
@@ -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<MarkerProps, "position">;
export const ClubMarker = forwardRef<MarkerRef, ClubMarkerProps>(
({ club, ...markerProps }, ref) => {
const markerRef = useRef<L.Marker<any> | 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: `
<svg x="0px" y="0px" viewBox="0 0 365 560" enable-background="new 0 0 365 560" xml:space="preserve">
<g><circle fill="#FFFFFF" cx="182" cy="180" r="70" /></g>
<g><path stroke="#666666" stroke-width="10" fill="${TimeControlColours.Classic}" d="M182.9,551.7c0,0.1,0.2,0.3,0.2,0.3S358.3,283,358.3,194.6c0-130.1-88.8-186.7-175.4-186.9 C96.3,7.9,7.5,64.5,7.5,194.6c0,88.4,175.3,357.4,175.3,357.4S182.9,551.7,182.9,551.7z M122.2,187.2c0-33.6,27.2-60.8,60.8-60.8 c33.6,0,60.8,27.2,60.8,60.8S216.5,248,182.9,248C149.4,248,122.2,220.8,122.2,187.2z"/></g>
</svg>
`,
className: "",
iconSize: [24, 40],
iconAnchor: [12, 40],
popupAnchor: [1, -40],
}),
[],
);
return (
<Marker
ref={markerRef}
position={latLng}
icon={iconOptions}
eventHandlers={{
mouseover: () => setHoveredMapId(id),
mouseout: () => setHoveredMapId(null),
}}
{...markerProps}
>
<Popup maxWidth={10000}>
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
<div className="flex flex-col gap-0">
<div>{club.name}</div>
{club.address && <div>{club.address}</div>}
{club.website && (
<div>
<a
href={club.website}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.website}
</a>
</div>
)}
{club.url && (
<div>
<a
href={club.url}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.url}
</a>
</div>
)}
{club.email && (
<div>
<a
href={`mailto:${club.email}`}
className="text-primary hover:text-primary-800"
>
{club.email}
</a>
</div>
)}
</div>
</div>
</Popup>
</Marker>
);
},
);
ClubMarker.displayName = "ClubMarker";
+148
View File
@@ -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 (
<section
className="club-table grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-content lg:overflow-y-scroll lg:pb-0"
id="listing"
>
<div className="z-10 flex w-full flex-wrap items-center justify-between gap-3 p-3">
<SearchBar />
<div className="flex flex-col gap-0 text-gray-900 dark:text-white">
<label>
<input
type="checkbox"
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
checked={syncVisible}
onChange={() => setSyncVisible(!syncVisible)}
/>
{t("syncWithMapCheckbox")}
</label>
</div>
</div>
<ScrollToTopButton />
<div className="overflow-x-scroll bg-red-50">
<table className="relative min-w-full table-fixed text-center text-xs lg:min-w-full">
<thead>
<tr>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("name")}
</th>
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("contact")}
</th>
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600">
{t("ffe")}
</th>
</tr>
</thead>
<tbody>
{filteredClubs.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>
) : (
filteredClubs.map((club) => (
<tr
key={club.id}
id={club.id}
onMouseEnter={() => 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",
)}
>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{club.name}
</td>
<td className="px-1 py-2 text-left sm:px-3 sm:py-3 lg:px-3 lg:py-3">
{club.address && <div>{club.address}</div>}
{club.website && (
<div>
<a
href={club.website}
className="text-primary hover:text-primary-800"
target="_blank"
>
{club.website}
</a>
</div>
)}
{club.email && (
<div>
<a
href={`mailto:${club.email}`}
className="text-primary hover:text-primary-800"
>
{club.email}
</a>
</div>
)}
</td>
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
<div className="flex justify-center">
<a
href={club.url}
target="_blank"
className="text-primary hover:text-primary-800"
>
<FaExternalLinkAlt />
</a>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
);
};
export default ClubTable;
+38
View File
@@ -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 (
<main className="relative grid h-full w-full lg:grid-cols-2">
<div>
<ClubMap />
</div>
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
<ClubTable />
</div>
</main>
);
}
+39
View File
@@ -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<ClubData>({}).toArray();
return data
.filter((c) => !!c.coordinates)
.map<Club>((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 <ClubsDisplay clubs={clubs} />;
}
+10
View File
@@ -70,6 +70,16 @@ const HamburgerMenu = ({
{t("tournaments")}
</Link>
</li>
<li className="py-5 text-center text-xl">
<Link
href="/clubs"
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
>
{t("clubs")}
</Link>
</li>
<li className="py-5 text-center text-xl">
<Link
href="/elo"
+1
View File
@@ -8,6 +8,7 @@ export default function Navbar() {
const links = [
{ title: t("tournaments"), route: "/tournois" },
{ title: t("clubs"), route: "/clubs" },
{ title: t("elo"), route: "/elo" },
];
+22
View File
@@ -79,3 +79,25 @@ export const filteredTournamentsListAtom = atom((get) => {
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));
});
+15
View File
@@ -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.",
+15
View File
@@ -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.",
+21 -1
View File
@@ -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;