mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 12:36:57 +00:00
Update packages (and fix intl)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import L from "leaflet";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMap } from "react-leaflet";
|
||||
|
||||
import { filteredTournamentsByTimeControlAtom } from "@/app/atoms";
|
||||
import { TimeControlColours } from "@/app/constants";
|
||||
import { TimeControl } from "@/types";
|
||||
|
||||
const Legend = () => {
|
||||
const at = useTranslations("App");
|
||||
const map = useMap();
|
||||
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
|
||||
|
||||
const timeControls = useMemo(
|
||||
() =>
|
||||
[
|
||||
TimeControl.Classic,
|
||||
TimeControl.Rapid,
|
||||
TimeControl.Blitz,
|
||||
TimeControl.Other,
|
||||
].filter((tc) => tournaments.some((t) => t.timeControl === tc)),
|
||||
[tournaments],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (map) {
|
||||
// @ts-ignore
|
||||
const legend = L.control({ position: "bottomleft" });
|
||||
|
||||
legend.onAdd = () => {
|
||||
const div = L.DomUtil.create("div", "map-legend");
|
||||
div.setAttribute(
|
||||
"style",
|
||||
"background: white; color: black; border: 2px solid grey; border-radius: 6px; padding: 10px;",
|
||||
);
|
||||
|
||||
div.innerHTML = `
|
||||
<ul>
|
||||
${timeControls
|
||||
.map(
|
||||
(tc) => `
|
||||
<li>
|
||||
<span class="block h-4 w-7 border border-[#999] float-left mr-1" style="background: ${
|
||||
TimeControlColours[tc]
|
||||
}"></span>${at("timeControlEnum", { tc })}
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</ul>`;
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
}
|
||||
}, [map, at]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Legend;
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { FaArrowUp } from "react-icons/fa";
|
||||
|
||||
import { handleScrollToTop } from "@/handlers/scrollHandlers";
|
||||
import { useBreakpoint } from "@/hooks/tailwind";
|
||||
import { ScrollableElement } from "@/types";
|
||||
|
||||
const ScrollToTopButton = () => {
|
||||
const scrollToTopElementRef = useRef<ScrollableElement | null>(null);
|
||||
const isLgScreen = useBreakpoint("lg");
|
||||
|
||||
// determine scrollable element based on screen size - window or div
|
||||
useEffect(() => {
|
||||
isLgScreen
|
||||
? (scrollToTopElementRef.current =
|
||||
document.getElementById("tournament-table"))
|
||||
: (scrollToTopElementRef.current = window);
|
||||
}, [isLgScreen]);
|
||||
|
||||
const scrollToTopButtonClass = isLgScreen
|
||||
? "absolute bottom-0 right-3 p-10"
|
||||
: "fixed bottom-20 right-3 py-10";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${scrollToTopButtonClass} z-10 text-2xl text-primary-900 dark:text-white`}
|
||||
data-test="scroll-to-top-button"
|
||||
>
|
||||
<FaArrowUp
|
||||
onClick={() => handleScrollToTop(scrollToTopElementRef.current)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollToTopButton;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useAtom } from "jotai/index";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { IoCloseOutline } from "react-icons/io5";
|
||||
|
||||
import { searchStringAtom } from "@/app/atoms";
|
||||
|
||||
const SearchBar = () => {
|
||||
const t = useTranslations("Tournaments");
|
||||
const [searchString, setSearchString] = useAtom(searchStringAtom);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800">
|
||||
<label htmlFor="table-search" className="sr-only">
|
||||
{t("searchLabel")}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-500 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{searchString !== "" && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-900 dark:text-white">
|
||||
<button onClick={() => setSearchString("")}>
|
||||
<IoCloseOutline className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="search"
|
||||
id="table-search"
|
||||
className="block rounded-lg border border-gray-300 bg-gray-50 p-2.5 px-10 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={searchString}
|
||||
onChange={(e) => setSearchString(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
blitzAtom,
|
||||
classicAtom,
|
||||
otherAtom,
|
||||
rapidAtom,
|
||||
tournamentsAtom,
|
||||
} from "@/app/atoms";
|
||||
import { TimeControl } from "@/types";
|
||||
|
||||
const TimeControlFilters = () => {
|
||||
const at = useTranslations("App");
|
||||
const tournaments = useAtomValue(tournamentsAtom);
|
||||
|
||||
const classic = useAtom(classicAtom);
|
||||
const rapid = useAtom(rapidAtom);
|
||||
const blitz = useAtom(blitzAtom);
|
||||
const other = useAtom(otherAtom);
|
||||
|
||||
const checkboxes = [
|
||||
{ tc: TimeControl.Classic, atom: classic },
|
||||
{ tc: TimeControl.Rapid, atom: rapid },
|
||||
{ tc: TimeControl.Blitz, atom: blitz },
|
||||
{ tc: TimeControl.Other, atom: other },
|
||||
].filter(({ tc }) => tournaments.some((t) => t.timeControl === tc));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{checkboxes.map(({ tc, atom }, i) => (
|
||||
<div key={i} className="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={atom[0]}
|
||||
onChange={() => atom[1](!atom[0])}
|
||||
/>
|
||||
{at("timeControlEnum", { tc })}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeControlFilters;
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import L, { DomUtil, LatLngLiteral, Marker } 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 { FaAngleDoubleDown } from "react-icons/fa";
|
||||
import { LayerGroup, MapContainer, TileLayer } from "react-leaflet";
|
||||
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||
|
||||
import MapEvents from "@/app/[locale]/components/MapEvents";
|
||||
import {
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
filteredTournamentsByTimeControlAtom,
|
||||
mapBoundsAtom,
|
||||
normsOnlyAtom,
|
||||
} from "@/app/atoms";
|
||||
import { TimeControlColours } from "@/app/constants";
|
||||
import { generatePieSVG } from "@/lib/pie";
|
||||
import { TimeControl } from "@/types";
|
||||
|
||||
import Legend from "./Legend";
|
||||
import TimeControlFilters from "./TimeControlFilters";
|
||||
import { TournamentMarker, TournamentMarkerRef } from "./TournamentMarker";
|
||||
|
||||
// Declare a class type that adds in methods etc. defined by leaflet.smooth_marker_bouncing
|
||||
// to keep Typescript happy
|
||||
declare class BouncingMarker extends Marker {
|
||||
isBouncing(): boolean;
|
||||
bounce(): void;
|
||||
stopBouncing(): void;
|
||||
|
||||
_icon: HTMLElement;
|
||||
_bouncingMotion?: {
|
||||
bouncingAnimationPlaying: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const stopBouncingMarkers = () => {
|
||||
const markers =
|
||||
// @ts-ignore
|
||||
Marker.prototype._orchestration.getBouncingMarkers() as BouncingMarker[];
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.isBouncing()) {
|
||||
try {
|
||||
marker.stopBouncing();
|
||||
// The plugin keeps bouncing until the end of the animation. We want to stop it immediately
|
||||
// An issue has been raised on the project (https://github.com/hosuaby/Leaflet.SmoothMarkerBouncing/issues/52), until then we have this hack.
|
||||
// We remove the class and reset some internal state
|
||||
|
||||
DomUtil.removeClass(marker._icon, "bouncing");
|
||||
if (marker?._bouncingMotion?.bouncingAnimationPlaying === true)
|
||||
marker._bouncingMotion.bouncingAnimationPlaying = false;
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default function TournamentMap() {
|
||||
const tournaments = useAtomValue(filteredTournamentsByTimeControlAtom);
|
||||
const normsOnly = useAtomValue(normsOnlyAtom);
|
||||
|
||||
const setMapBounds = useSetAtom(mapBoundsAtom);
|
||||
const hoveredListTournamentId = useAtomValue(
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
);
|
||||
|
||||
const markerRefs = useRef<Record<string, TournamentMarkerRef>>({});
|
||||
const clusterRef = useRef<L.MarkerClusterGroup | null>(null);
|
||||
const expandedClusterMarkerRef = useRef<L.MarkerCluster | null>(null);
|
||||
|
||||
const filteredTournaments = useMemo(
|
||||
() => tournaments.filter((t) => (normsOnly ? t.norm : true)),
|
||||
[normsOnly, tournaments],
|
||||
);
|
||||
|
||||
const groupedTournaments = groupBy(filteredTournaments, (t) => t.groupId);
|
||||
|
||||
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
||||
|
||||
const onScrollToTable = () => {
|
||||
const tournamentTable = document.getElementById("tournament-table");
|
||||
tournamentTable?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const expandAndBounceIfNeeded = useCallback(() => {
|
||||
if (hoveredListTournamentId) {
|
||||
const tournament = filteredTournaments.find(
|
||||
(t) => t.id === hoveredListTournamentId,
|
||||
);
|
||||
|
||||
if (tournament) {
|
||||
const markerRef = markerRefs.current[tournament.groupId];
|
||||
if (markerRef) {
|
||||
if (clusterRef.current) {
|
||||
const visibleMarker = clusterRef.current.getVisibleParent(
|
||||
markerRef.getMarker(),
|
||||
);
|
||||
if (!visibleMarker) return;
|
||||
|
||||
// @ts-ignore
|
||||
if (visibleMarker.__proto__ === L.MarkerCluster.prototype) {
|
||||
// This is a cluster icon, we expand it.
|
||||
const clusterMarker = visibleMarker as L.MarkerCluster;
|
||||
|
||||
if (
|
||||
expandedClusterMarkerRef.current &&
|
||||
expandedClusterMarkerRef.current !== clusterMarker
|
||||
) {
|
||||
expandedClusterMarkerRef.current.unspiderfy();
|
||||
}
|
||||
|
||||
clusterMarker.spiderfy();
|
||||
|
||||
// Sometimes there marker that's still bouncing from the last time the group was expanded.
|
||||
// We stop it quickly.
|
||||
|
||||
setTimeout(() => {
|
||||
stopBouncingMarkers();
|
||||
}, 50);
|
||||
} else {
|
||||
// This is a standard marker, we bounce it.
|
||||
const marker = visibleMarker as BouncingMarker;
|
||||
if (!marker.isBouncing()) {
|
||||
stopBouncingMarkers();
|
||||
|
||||
markerRef.bounce();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopBouncingMarkers();
|
||||
return false;
|
||||
}, [filteredTournaments, hoveredListTournamentId]);
|
||||
|
||||
const onSpiderified = useCallback(
|
||||
(e: L.MarkerClusterSpiderfyEvent) => {
|
||||
// Once expanded, bounce the appropriate marker
|
||||
|
||||
if (hoveredListTournamentId) {
|
||||
const tournament = filteredTournaments.find(
|
||||
(t) => t.id === hoveredListTournamentId,
|
||||
);
|
||||
if (!tournament) return;
|
||||
|
||||
expandedClusterMarkerRef.current = e.cluster;
|
||||
const markerRef = markerRefs.current[tournament.groupId];
|
||||
|
||||
if (markerRef && e.markers.includes(markerRef.getMarker())) {
|
||||
stopBouncingMarkers();
|
||||
markerRef.bounce();
|
||||
}
|
||||
}
|
||||
},
|
||||
[filteredTournaments, hoveredListTournamentId],
|
||||
);
|
||||
|
||||
const onUnSpiderified = useCallback(
|
||||
(e: L.MarkerClusterSpiderfyEvent) => {
|
||||
if (expandedClusterMarkerRef.current === e.cluster)
|
||||
expandedClusterMarkerRef.current = null;
|
||||
|
||||
// Once closed, we can expand the next group if needed
|
||||
expandAndBounceIfNeeded();
|
||||
},
|
||||
[expandAndBounceIfNeeded],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ref = clusterRef.current;
|
||||
|
||||
if (clusterRef.current) {
|
||||
clusterRef.current.on("spiderfied", onSpiderified);
|
||||
clusterRef.current.on("unspiderfied", onUnSpiderified);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ref) {
|
||||
ref.off("spiderfied", onSpiderified);
|
||||
ref.off("unspiderfied", onUnSpiderified);
|
||||
}
|
||||
};
|
||||
}, [onSpiderified, onUnSpiderified]);
|
||||
|
||||
useEffect(() => {
|
||||
// Expand/contract as hoveredListTournamentId changes
|
||||
if (expandAndBounceIfNeeded()) return;
|
||||
|
||||
if (expandedClusterMarkerRef.current)
|
||||
expandedClusterMarkerRef.current.unspiderfy();
|
||||
}, [expandAndBounceIfNeeded, hoveredListTournamentId]);
|
||||
|
||||
const createClusterCustomIcon = useCallback((cluster: any) => {
|
||||
const childCount = cluster.getChildCount();
|
||||
const children = cluster.getAllChildMarkers();
|
||||
|
||||
// We added the time control as the class name when creating the marker
|
||||
const timeControlCounts = countBy(
|
||||
children,
|
||||
(child: any) => child.options.icon.options.className,
|
||||
);
|
||||
|
||||
const html = `
|
||||
<div>
|
||||
${generatePieSVG(
|
||||
"absolute w-[30px]",
|
||||
15,
|
||||
[
|
||||
TimeControl.Classic,
|
||||
TimeControl.Rapid,
|
||||
TimeControl.Blitz,
|
||||
TimeControl.Other,
|
||||
].map((tc) => ({
|
||||
value: timeControlCounts[tc] ?? 0,
|
||||
colour: TimeControlColours[tc],
|
||||
})),
|
||||
)}
|
||||
<span class="text-white font-semibold relative z-[300]">${childCount}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new L.DivIcon({
|
||||
html,
|
||||
className: "marker-cluster bg-gray-600/20",
|
||||
iconSize: new L.Point(40, 40),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markers = useMemo(
|
||||
() =>
|
||||
Object.values(groupedTournaments).map((tournamentGroup) => {
|
||||
const { groupId } = tournamentGroup[0];
|
||||
return (
|
||||
<TournamentMarker
|
||||
ref={(ref) => {
|
||||
markerRefs.current[groupId] = ref!;
|
||||
}}
|
||||
key={groupId}
|
||||
tournamentGroup={tournamentGroup}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[groupedTournaments],
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="tournament-map" className="flex h-content flex-col">
|
||||
<div className="p-3 lg:hidden">
|
||||
<TimeControlFilters />
|
||||
</div>
|
||||
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={5}
|
||||
scrollWheelZoom={false}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ref={(map) => {
|
||||
if (map) {
|
||||
setMapBounds(map.getBounds());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MapEvents />
|
||||
<TileLayer
|
||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Legend />
|
||||
<LayerGroup>
|
||||
<MarkerClusterGroup
|
||||
ref={clusterRef}
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterCustomIcon}
|
||||
maxClusterRadius={40}
|
||||
showCoverageOnHover={false}
|
||||
spiderfyOnMaxZoom
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
</LayerGroup>
|
||||
$
|
||||
</MapContainer>
|
||||
|
||||
<div className="flex items-center justify-center lg:hidden">
|
||||
<button
|
||||
className="p-3 text-primary-900 dark:text-white"
|
||||
onClick={onScrollToTable}
|
||||
>
|
||||
<FaAngleDoubleDown />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import L from "leaflet";
|
||||
import { last } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FaTrophy } from "react-icons/fa";
|
||||
import { Marker, MarkerProps, Popup } from "react-leaflet";
|
||||
|
||||
import { debouncedHoveredMapTournamentGroupIdAtom } from "@/app/atoms";
|
||||
import { TimeControlColours } from "@/app/constants";
|
||||
import type { BouncingMarker } from "@/leafletTypes";
|
||||
import { Tournament } from "@/types";
|
||||
|
||||
export type TournamentMarkerRef = {
|
||||
getMarker: () => L.Marker<any>;
|
||||
bounce: () => void;
|
||||
};
|
||||
|
||||
type TournamentMarkerProps = {
|
||||
tournamentGroup: Tournament[];
|
||||
} & Omit<MarkerProps, "position">;
|
||||
|
||||
export const TournamentMarker = forwardRef<
|
||||
TournamentMarkerRef,
|
||||
TournamentMarkerProps
|
||||
>(({ tournamentGroup, ...markerProps }, ref) => {
|
||||
const t = useTranslations("Tournaments");
|
||||
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 { date, latLng, groupId, timeControl } = tournamentGroup[0];
|
||||
|
||||
const setHoveredMapTournamentGroupId = useSetAtom(
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
|
||||
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[timeControl]}" 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: timeControl,
|
||||
iconSize: [24, 40],
|
||||
iconAnchor: [12, 40],
|
||||
popupAnchor: [1, -40],
|
||||
}),
|
||||
[timeControl],
|
||||
);
|
||||
|
||||
const startDate = date;
|
||||
const endDate = last(tournamentGroup)!.date;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
position={latLng}
|
||||
icon={iconOptions}
|
||||
eventHandlers={{
|
||||
mouseover: () => setHoveredMapTournamentGroupId(groupId),
|
||||
mouseout: () => setHoveredMapTournamentGroupId(null),
|
||||
}}
|
||||
{...markerProps}
|
||||
>
|
||||
<Popup maxWidth={10000}>
|
||||
<div className="flex max-w-[calc(100vw-80px)] flex-col gap-3 lg:max-w-[calc(100vw/2-80px)]">
|
||||
<b>
|
||||
{date}
|
||||
{endDate !== startDate && ` - ${endDate}`}
|
||||
</b>
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
{tournamentGroup.map((tournament) => (
|
||||
<a
|
||||
key={tournament.id}
|
||||
href={tournament.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tournament.tournament}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tournamentGroup.some((t) => t.norm) && (
|
||||
<div className="flex items-center">
|
||||
<FaTrophy className="mr-3 h-4 w-4" />
|
||||
{t("norm")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t("approx")}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
});
|
||||
|
||||
TournamentMarker.displayName = "TournamentMarker";
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { FaTrophy } from "react-icons/fa";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
filteredTournamentsListAtom,
|
||||
hoveredMapTournamentGroupIdAtom,
|
||||
normsOnlyAtom,
|
||||
syncVisibleAtom,
|
||||
} from "@/app/atoms";
|
||||
import { useBreakpoint } from "@/hooks/tailwind";
|
||||
|
||||
import ScrollToTopButton from "./ScrollToTopButton";
|
||||
import SearchBar from "./SearchBar";
|
||||
import TimeControlFilters from "./TimeControlFilters";
|
||||
|
||||
export default function TournamentTable() {
|
||||
const t = useTranslations("Tournaments");
|
||||
const at = useTranslations("App");
|
||||
|
||||
const filteredTournaments = useAtomValue(filteredTournamentsListAtom);
|
||||
const [syncVisible, setSyncVisible] = useAtom(syncVisibleAtom);
|
||||
const [normsOnly, setNormsOnly] = useAtom(normsOnlyAtom);
|
||||
const hoveredMapTournamentGroupId = useAtomValue(
|
||||
hoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
const debouncedHoveredMapTournamentGroupId = useAtomValue(
|
||||
debouncedHoveredMapTournamentGroupIdAtom,
|
||||
);
|
||||
const setHoveredListTournamentId = useSetAtom(
|
||||
debouncedHoveredListTournamentIdAtom,
|
||||
);
|
||||
|
||||
const isLg = useBreakpoint("lg");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLg || debouncedHoveredMapTournamentGroupId === null) return;
|
||||
const tournamentRow = document.querySelector(
|
||||
`[data-group-id="${debouncedHoveredMapTournamentGroupId}"]`,
|
||||
);
|
||||
|
||||
tournamentRow?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [debouncedHoveredMapTournamentGroupId, isLg]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tournament-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="tournament-table"
|
||||
data-test="tournament-table-div"
|
||||
>
|
||||
<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>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2 h-4 w-4 rounded border-gray-400 text-primary focus:ring-primary"
|
||||
checked={normsOnly}
|
||||
onChange={() => setNormsOnly(!normsOnly)}
|
||||
/>
|
||||
{t("normsOnly")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<TimeControlFilters />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollToTopButton />
|
||||
|
||||
<div className="overflow-x-scroll">
|
||||
<table
|
||||
className="relative min-w-full table-fixed text-center text-xs lg:w-full"
|
||||
data-test="tournament-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("date")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("town")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("tournament")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-primary-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("timeControl")}
|
||||
</th>
|
||||
<th className="sticky top-0 w-[50px] bg-primary-600 p-3 text-white dark:bg-gray-600"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{filteredTournaments.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>
|
||||
) : (
|
||||
filteredTournaments.map((tournament) => (
|
||||
<tr
|
||||
key={tournament.id}
|
||||
id={tournament.id}
|
||||
data-group-id={tournament.groupId}
|
||||
onMouseEnter={() => setHoveredListTournamentId(tournament.id)}
|
||||
onMouseLeave={() => setHoveredListTournamentId(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",
|
||||
hoveredMapTournamentGroupId === tournament.groupId &&
|
||||
"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">
|
||||
{tournament.date}
|
||||
</td>
|
||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||
{tournament.town}
|
||||
</td>
|
||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||
<span>
|
||||
{tournament.norm && (
|
||||
<FaTrophy
|
||||
className="mr-2 inline-block h-4 w-4"
|
||||
data-norm="norm"
|
||||
/>
|
||||
)}
|
||||
{tournament.tournament}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||
{at("timeControlEnum", { tc: tournament.timeControl })}
|
||||
</td>
|
||||
<td className="px-1 py-2 sm:px-3 sm:py-3 lg:px-3 lg:py-3">
|
||||
<a href={tournament.url} target="_blank">
|
||||
<FaExternalLinkAlt />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Tooltip anchorSelect="[data-norm='norm']">
|
||||
<div className="flex items-center">
|
||||
<FaTrophy className="mr-3 h-4 w-4" />
|
||||
{t("norm")}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useHydrateAtoms } from "jotai/utils";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import LoadingMap from "@/app/[locale]/components/LoadingMap";
|
||||
import { tournamentsAtom } from "@/app/atoms";
|
||||
import { Tournament } from "@/types";
|
||||
|
||||
import TournamentTable from "./TournamentTable";
|
||||
|
||||
type TournamentsDisplayProps = {
|
||||
tournaments: Tournament[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Imports the tournament map component, ensuring CSR only.
|
||||
* @remarks SSR is not supported by react-leaflet
|
||||
*/
|
||||
const TournamentMap = dynamic(() => import("./TournamentMap"), {
|
||||
ssr: false,
|
||||
loading: LoadingMap,
|
||||
});
|
||||
|
||||
export default function TournamentsDisplay({
|
||||
tournaments,
|
||||
}: TournamentsDisplayProps) {
|
||||
useHydrateAtoms([[tournamentsAtom, tournaments]]);
|
||||
|
||||
return (
|
||||
<main className="relative grid h-full w-full lg:grid-cols-2">
|
||||
<div>
|
||||
<TournamentMap />
|
||||
</div>
|
||||
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
|
||||
<TournamentTable />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { differenceInDays, isSameDay, parse } from "date-fns";
|
||||
import { groupBy } from "lodash";
|
||||
|
||||
import clientPromise from "@/lib/mongodb";
|
||||
import { TournamentData } from "@/types";
|
||||
import { TimeControl, Tournament } from "@/types";
|
||||
import { errorLog } from "@/utils/logger";
|
||||
|
||||
import TournamentsDisplay from "./TournamentsDisplay";
|
||||
|
||||
export const revalidate = 3600; // Revalidate cache every 6 hours
|
||||
|
||||
const tcMap: Record<TournamentData["time_control"], TimeControl> = {
|
||||
"Cadence Lente": TimeControl.Classic,
|
||||
Rapide: TimeControl.Rapid,
|
||||
Blitz: TimeControl.Blitz,
|
||||
};
|
||||
|
||||
const getTournaments = async () => {
|
||||
try {
|
||||
const client = await clientPromise;
|
||||
const db = client.db("tournamentsFranceDB");
|
||||
const data = await db
|
||||
.collection("tournaments")
|
||||
.aggregate<TournamentData>([
|
||||
{
|
||||
$addFields: {
|
||||
dateParts: {
|
||||
$dateFromString: {
|
||||
dateString: "$date",
|
||||
format: "%d/%m/%Y",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
dateParts: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$unset: "dateParts",
|
||||
},
|
||||
])
|
||||
.toArray();
|
||||
|
||||
// Group the tournaments by their location
|
||||
const groupedByLocation = groupBy(
|
||||
data,
|
||||
(t) => `${t.coordinates[0]}_${t.coordinates[1]}`,
|
||||
);
|
||||
|
||||
// For each location, create an array of arrays of contiguous dates for this location
|
||||
const dateRangesByLocation: Record<string, Date[][]> = {};
|
||||
for (const location in groupedByLocation) {
|
||||
const tournaments = groupedByLocation[location];
|
||||
|
||||
// Note that this works since the tournaments are sorted by date
|
||||
const dateRanges = tournaments.reduce<Date[][]>(
|
||||
(acc, tournament) => {
|
||||
const group = acc[acc.length - 1];
|
||||
const date = parse(tournament.date, "dd/MM/yyyy", new Date());
|
||||
const diff = differenceInDays(date, group[group.length - 1] ?? date);
|
||||
if (diff > 1) {
|
||||
acc.push([date]);
|
||||
} else if (group.length === 0 || diff === 1) {
|
||||
group.push(date);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[[]],
|
||||
);
|
||||
|
||||
dateRangesByLocation[location] = dateRanges;
|
||||
}
|
||||
|
||||
return data.map<Tournament>((t) => {
|
||||
const location = `${t.coordinates[0]}_${t.coordinates[1]}`;
|
||||
const date = parse(t.date, "dd/MM/yyyy", new Date());
|
||||
const dateRanges = dateRangesByLocation[location];
|
||||
const rangeIndex = dateRanges.findIndex((ranges) =>
|
||||
ranges.some((d) => isSameDay(d, date)),
|
||||
);
|
||||
|
||||
// We place each tournament into a group based on location and date and time control, so that
|
||||
// we can display a single map marker.
|
||||
const timeControl = tcMap[t.time_control] ?? TimeControl.Other;
|
||||
const groupId = `${location}_${rangeIndex}_${timeControl}`;
|
||||
|
||||
return {
|
||||
id: t._id.toString(),
|
||||
groupId,
|
||||
tournament: t.tournament,
|
||||
town: t.town,
|
||||
department: t.department,
|
||||
date: t.date,
|
||||
url: t.url,
|
||||
timeControl,
|
||||
latLng: { lat: t.coordinates[0], lng: t.coordinates[1] },
|
||||
norm: t.norm_tournament,
|
||||
pending: t.pending,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
errorLog(error);
|
||||
throw new Error("Error fetching tournament data");
|
||||
}
|
||||
};
|
||||
|
||||
export default async function Tournaments() {
|
||||
const tournaments = await getTournaments();
|
||||
return <TournamentsDisplay tournaments={tournaments} />;
|
||||
}
|
||||
Reference in New Issue
Block a user