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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user