mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Clubs page
This commit is contained in:
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user