mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Fix layout issues & co-locate components (#46)
* Fix layout issues & co-locate components * Fix front page * Fix centering
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import Link from "next-intl/link";
|
||||
import { FaGithub, FaRegEnvelope } from "react-icons/fa";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations("Footer");
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="fixed bottom-0 z-30 flex h-20 w-full flex-col items-center justify-center justify-items-center bg-teal-600 px-5 py-2 text-white dark:bg-gray-700"
|
||||
data-test="footer"
|
||||
>
|
||||
<div>
|
||||
<p>© {new Date().getFullYear()} - Owen Rees</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4 p-2">
|
||||
<Link
|
||||
href="https://github.com/TheRealOwenRees/echecsfrance"
|
||||
target="_blank"
|
||||
aria-label={t("githubAria")}
|
||||
>
|
||||
<FaGithub />
|
||||
</Link>
|
||||
<Link href="/contactez-nous" aria-label={t("contactAria")}>
|
||||
<FaRegEnvelope />
|
||||
</Link>
|
||||
<div className="space-x-2 text-xs">
|
||||
<Link href="/" locale="fr">
|
||||
FR
|
||||
</Link>
|
||||
<span>|</span>
|
||||
<Link href="/" locale="en">
|
||||
EN
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import HamburgerMenu from "./HamburgerMenu";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
const Hamburger = () => {
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const hamburgerButtonRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={hamburgerButtonRef}
|
||||
className="hamburger-button relative z-[99999] space-y-2"
|
||||
data-test="hamburger-button"
|
||||
onClick={() => setMenuVisible(!menuVisible)}
|
||||
>
|
||||
<div
|
||||
className={`h-0.5 w-8 bg-gray-600 transition-transform duration-300 ease-in-out dark:bg-white ${
|
||||
menuVisible ? "translate-x-[1px] translate-y-2.5 rotate-45" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`h-0.5 w-8 bg-gray-600 transition-transform duration-300 ease-linear dark:bg-white ${
|
||||
menuVisible ? "rotate-0 scale-0 opacity-0" : ""
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`h-0.5 w-8 bg-gray-600 transition-transform duration-300 ease-in-out dark:bg-white ${
|
||||
menuVisible ? "-translate-y-2.5 -rotate-45" : ""
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<HamburgerMenu
|
||||
menuVisible={menuVisible}
|
||||
setMenuVisible={setMenuVisible}
|
||||
hamburgerButtonRef={hamburgerButtonRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hamburger;
|
||||
@@ -0,0 +1,95 @@
|
||||
interface HamburgerMenuState {
|
||||
menuVisible: boolean;
|
||||
setMenuVisible: Dispatch<SetStateAction<boolean>>;
|
||||
hamburgerButtonRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
import { Dispatch, RefObject, SetStateAction, useRef, useState } from "react";
|
||||
import Link from "next-intl/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import useHamburgerClose from "@/hooks/useHamburgerClose";
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
|
||||
const HamburgerMenu = ({
|
||||
menuVisible,
|
||||
setMenuVisible,
|
||||
hamburgerButtonRef,
|
||||
}: HamburgerMenuState) => {
|
||||
const t = useTranslations("Nav");
|
||||
const [mouseOverMenu, setMouseOverMenu] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuTimeout = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMenuVisible(false);
|
||||
setMouseOverMenu(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleMouseEnterMenu = () => {
|
||||
setMouseOverMenu(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
};
|
||||
|
||||
const handleMouseLeaveMenu = () => {
|
||||
setMouseOverMenu(false);
|
||||
clearTimeout(timeoutRef.current);
|
||||
};
|
||||
|
||||
useHamburgerClose({
|
||||
menuVisible,
|
||||
setMenuVisible,
|
||||
menuRef,
|
||||
hamburgerButtonRef,
|
||||
mouseOverMenu,
|
||||
setMouseOverMenu,
|
||||
timeoutRef,
|
||||
menuTimeout,
|
||||
});
|
||||
|
||||
// noinspection HtmlUnknownTarget
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`fixed right-0 top-0 ${
|
||||
menuVisible ? "" : "translate-x-full"
|
||||
} z-[9999] flex bg-teal-600 transition-transform duration-200 ease-linear dark:bg-gray-600 md:hidden`}
|
||||
onMouseEnter={handleMouseEnterMenu}
|
||||
onMouseLeave={handleMouseLeaveMenu}
|
||||
>
|
||||
<ul className="list-reset mt-16 p-5 text-white">
|
||||
<li className="py-5">
|
||||
<Link
|
||||
href="/tournois"
|
||||
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
|
||||
>
|
||||
{t("tournaments")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="py-5">
|
||||
<Link
|
||||
href="/qui-sommes-nous"
|
||||
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
|
||||
>
|
||||
{t("about")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="py-5">
|
||||
<Link
|
||||
href="/contactez-nous"
|
||||
className="border-b-2 border-transparent transition-all duration-300 ease-in-out hover:border-white"
|
||||
>
|
||||
{t("contact")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="py-5">
|
||||
<ThemeSwitcher />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HamburgerMenu;
|
||||
@@ -0,0 +1,65 @@
|
||||
import Link from "next-intl/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
import Hamburger from "./Hamburger";
|
||||
|
||||
export default function Navbar() {
|
||||
const t = useTranslations("Nav");
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="relative mt-0 h-16 w-full overflow-x-clip border-b-[1px] bg-white px-5 dark:border-gray-700 dark:bg-gray-800"
|
||||
data-test="navbar"
|
||||
>
|
||||
<div className="container mx-auto flex h-full items-center">
|
||||
<div className="flex w-full justify-center font-extrabold md:w-1/2 md:justify-start">
|
||||
<Link
|
||||
className="text-gray-900 no-underline hover:no-underline dark:text-white"
|
||||
href="/"
|
||||
>
|
||||
<span className="text-2xl">{t("title")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="pb-2 md:hidden" data-test="mobile-menu">
|
||||
<Hamburger />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden h-full justify-center md:flex md:w-1/2 md:justify-end"
|
||||
data-test="desktop-menu"
|
||||
>
|
||||
<ul className="list-reset flex h-full flex-1 items-center justify-around text-gray-900 no-underline dark:text-white md:flex-none">
|
||||
<li className="mr-10 h-full">
|
||||
<Link
|
||||
className="inline-flex h-full items-center border-b-4 border-t-4 border-transparent transition-all duration-300 ease-in-out hover:border-b-teal-600"
|
||||
href="/tournois"
|
||||
>
|
||||
{t("tournaments")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mr-10 h-full">
|
||||
<Link
|
||||
className="inline-flex h-full items-center border-b-4 border-t-4 border-transparent transition-all duration-300 ease-in-out hover:border-b-teal-600"
|
||||
href="/qui-sommes-nous"
|
||||
>
|
||||
{t("about")}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mr-10 h-full">
|
||||
<Link
|
||||
className="inline-flex h-full items-center border-b-4 border-t-4 border-transparent transition-all duration-300 ease-in-out hover:border-b-teal-600"
|
||||
href="/contactez-nous"
|
||||
>
|
||||
{t("contact")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<ThemeSwitcher />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextIntlClientProvider, AbstractIntlMessages } from "next-intl";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import "@/app/globals.css";
|
||||
import Navbar from "./Navbar";
|
||||
import Footer from "./Footer";
|
||||
|
||||
const TestableLayout = ({
|
||||
children,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
locale: string;
|
||||
messages: AbstractIntlMessages;
|
||||
}) => {
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<div className="flex min-h-screen flex-col bg-white font-sans leading-normal tracking-normal dark:bg-gray-800">
|
||||
<Navbar />
|
||||
<div className="relative mb-20 flex flex-1 justify-center">
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestableLayout;
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { MdBrightness2, MdCircle } from "react-icons/md";
|
||||
import useDarkMode from "@/hooks/useDarkMode";
|
||||
import { useEffect, useState } from "react";
|
||||
import "@/css/theme-toggle.css";
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [colorTheme, setTheme] = useDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{colorTheme === "light" ? (
|
||||
<div
|
||||
className="toggle-dark"
|
||||
data-test="toggle-dark"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<MdBrightness2 className="theme-icon-dark" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="toggle"
|
||||
data-test="toggle"
|
||||
onClick={() => setTheme("dark")}
|
||||
>
|
||||
<MdCircle className="theme-icon" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcher;
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import sendMail from "@/lib/sendMail";
|
||||
import { errorLog } from "@/utils/logger";
|
||||
import useContactForm from "@/hooks/useContactForm";
|
||||
|
||||
const ContactForm = () => {
|
||||
const t = useTranslations("Contact");
|
||||
const { values, handleChange, resetForm } = useContactForm();
|
||||
const [responseMessage, setResponseMessage] = useState({
|
||||
isSuccessful: false,
|
||||
message: "",
|
||||
});
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const clearMessage = () => {
|
||||
setTimeout(() => {
|
||||
setResponseMessage({
|
||||
isSuccessful: false,
|
||||
message: "",
|
||||
});
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSending(true);
|
||||
try {
|
||||
const response = await sendMail(values);
|
||||
if (response.status === 250) {
|
||||
setResponseMessage({
|
||||
isSuccessful: true,
|
||||
message: t("success"),
|
||||
});
|
||||
|
||||
resetForm();
|
||||
clearMessage();
|
||||
setIsSending(false);
|
||||
}
|
||||
} catch (error) {
|
||||
errorLog(error);
|
||||
setResponseMessage({
|
||||
isSuccessful: false,
|
||||
message: t("failure"),
|
||||
});
|
||||
clearMessage();
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const infoMessage = (
|
||||
<p
|
||||
className={`${
|
||||
responseMessage.isSuccessful ? "text-green-600" : "text-red-600"
|
||||
} italic`}
|
||||
data-test="info-message"
|
||||
>
|
||||
{responseMessage.message}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-8">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{t("emailLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={values.email}
|
||||
onChange={handleChange}
|
||||
type="email"
|
||||
id="email"
|
||||
className="focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder="nom@exemple.com"
|
||||
required
|
||||
data-test="email-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{t("subjectLabel")}
|
||||
</label>
|
||||
<input
|
||||
value={values.subject}
|
||||
onChange={handleChange}
|
||||
type="text"
|
||||
id="subject"
|
||||
className="focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 dark:shadow-sm-light block w-full rounded-lg border border-gray-300 bg-gray-50 p-3 text-sm text-gray-900 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder={t("subjectPlaceholder")}
|
||||
required
|
||||
data-test="subject-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{t("messageLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={values.message}
|
||||
onChange={handleChange}
|
||||
id="message"
|
||||
rows={6}
|
||||
className="focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
|
||||
placeholder={t("messagePlaceholder")}
|
||||
data-test="message-input"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
disabled={isSending}
|
||||
type="submit"
|
||||
className="hover:bg-primary-800 focus:ring-primary-300 dark:hover:bg-primary-700 dark:focus:ring-primary-800 rounded-lg bg-teal-600 px-5 py-3 text-center text-sm font-medium text-white focus:outline-none focus:ring-4 disabled:opacity-25 dark:text-white sm:w-fit"
|
||||
data-test="submit-button"
|
||||
>
|
||||
{isSending ? t("sending") : t("sendButton")}
|
||||
</button>
|
||||
{infoMessage}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
@@ -1,28 +1,25 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
import ContactForm from "./ContactForm";
|
||||
|
||||
// TODO fix page sizing
|
||||
export default function Contact() {
|
||||
const t = useTranslations("Contact");
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="grid place-items-center bg-white dark:bg-gray-800">
|
||||
<div className="mx-auto max-w-screen-md px-4 pb-32 pt-8 lg:pt-16">
|
||||
<h2
|
||||
className="mb-4 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
|
||||
{t("info")}
|
||||
</p>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
<section className="grid place-items-center bg-white dark:bg-gray-800">
|
||||
<div className="mx-auto max-w-screen-md px-4 pb-16 pt-8 lg:pt-16">
|
||||
<h2
|
||||
className="mb-4 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mb-8 text-center font-light text-gray-500 dark:text-gray-400 sm:text-xl lg:mb-16">
|
||||
{t("info")}
|
||||
</p>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { Inter } from "next/font/google";
|
||||
import { useLocale, NextIntlClientProvider } from "next-intl";
|
||||
import { useLocale } from "next-intl";
|
||||
import { getTranslator } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import "@/app/globals.css";
|
||||
import TestableLayout from "./components/TestableLayout";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -49,9 +50,9 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className={inter.className}>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<TestableLayout locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</TestableLayout>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+31
-34
@@ -2,45 +2,42 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import bgImage from "@/public/images/map-bg.jpg";
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations("Home");
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<header className="grid h-[calc(100%-153px)] md:h-[calc(100%-173px)] items-center">
|
||||
<div className="relative h-full w-full brightness-[0.2]">
|
||||
<Image
|
||||
src={bgImage}
|
||||
alt="Background image of France"
|
||||
fill={true}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute flex flex-col items-center w-full text-center text-white">
|
||||
<h1 className="p-5 text-5xl" data-test="header1">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<h2 className="p-5 text-3xl">{t("purpose")}</h2>
|
||||
<h3 className="mb-5 p-5 text-xl">
|
||||
{t.rich("how", {
|
||||
link: (chunks) => (
|
||||
<Link href="http://www.echecs.asso.fr/" target="_blank">
|
||||
<abbr title="Fédération Française des Échecs">{chunks}</abbr>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</h3>
|
||||
<Link
|
||||
href="/tournois"
|
||||
className="mb-2 mr-2 rounded-lg bg-gradient-to-r from-teal-400 via-teal-500 to-teal-600 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-br focus:outline-none focus:ring-4 focus:ring-teal-300 dark:focus:ring-teal-800"
|
||||
>
|
||||
{t("mapLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
</Layout>
|
||||
<header className="relative flex flex-1 items-center justify-center">
|
||||
<div className="absolute h-full w-full brightness-[0.2]">
|
||||
<Image
|
||||
src={bgImage}
|
||||
alt="Background image of France"
|
||||
fill={true}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-10 text-center text-white">
|
||||
<h1 className="p-5 text-5xl" data-test="header1">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<h2 className="p-5 text-3xl">{t("purpose")}</h2>
|
||||
<h3 className="mb-5 p-5 text-xl">
|
||||
{t.rich("how", {
|
||||
link: (chunks) => (
|
||||
<Link href="http://www.echecs.asso.fr/" target="_blank">
|
||||
<abbr title="Fédération Française des Échecs">{chunks}</abbr>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</h3>
|
||||
<Link
|
||||
href="/tournois"
|
||||
className="mb-2 mr-2 rounded-lg bg-gradient-to-r from-teal-400 via-teal-500 to-teal-600 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-gradient-to-br focus:outline-none focus:ring-4 focus:ring-teal-300 dark:focus:ring-teal-800"
|
||||
>
|
||||
{t("mapLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+105
-109
@@ -1,121 +1,117 @@
|
||||
import Link from "next-intl/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
|
||||
export default function About() {
|
||||
const t = useTranslations("About");
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<header className="grid place-items-center bg-white dark:bg-gray-800">
|
||||
<div className="h-full max-w-5xl bg-white px-4 pb-12 pt-8 dark:bg-gray-800">
|
||||
<h2
|
||||
className="mb-8 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("p1")}
|
||||
</p>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("p2", {
|
||||
homeLink: (chunks) => (
|
||||
<Link href="/" className="text-teal-600">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("p3")}
|
||||
</p>
|
||||
<h3
|
||||
className="mb-4 mt-8 text-center text-2xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("thanksTitle")}
|
||||
</h3>
|
||||
<ul className="mb-4 text-center text-teal-600">
|
||||
<li>
|
||||
<Link href="https://github.com/AlvaroNW" target="_blank">
|
||||
AlvaroNW
|
||||
<header className="mb-16 grid place-items-center bg-white dark:bg-gray-800">
|
||||
<div className="h-full max-w-5xl bg-white px-4 pt-8 dark:bg-gray-800">
|
||||
<h2
|
||||
className="mb-8 text-center text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("p1")}
|
||||
</p>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("p2", {
|
||||
homeLink: (chunks) => (
|
||||
<Link href="/" className="text-teal-600">
|
||||
{chunks}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://github.com/Florifourchette" target="_blank">
|
||||
Florifourchette
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("p3")}
|
||||
</p>
|
||||
<h3
|
||||
className="mb-4 mt-8 text-center text-2xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("thanksTitle")}
|
||||
</h3>
|
||||
<ul className="mb-4 text-center text-teal-600">
|
||||
<li>
|
||||
<Link href="https://github.com/AlvaroNW" target="_blank">
|
||||
AlvaroNW
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://github.com/Florifourchette" target="_blank">
|
||||
Florifourchette
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<h3
|
||||
className="mb-4 mt-8 text-center text-2xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("techTitle")}
|
||||
</h3>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("techInfo", {
|
||||
homeLink: (chunks) => (
|
||||
<Link href="/" className="text-teal-600">
|
||||
{chunks}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<h3
|
||||
className="mb-4 mt-8 text-center text-2xl font-extrabold tracking-tight text-gray-900 dark:text-white"
|
||||
data-test="header2"
|
||||
>
|
||||
{t("techTitle")}
|
||||
</h3>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("techInfo", {
|
||||
homeLink: (chunks) => (
|
||||
<Link href="/" className="text-teal-600">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<ul className="mb-4 flex justify-around text-teal-600">
|
||||
<li>
|
||||
<Link href="https://nextjs.org/" target="_blank">
|
||||
NextJS
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<ul className="mb-4 flex justify-around text-teal-600">
|
||||
<li>
|
||||
<Link href="https://nextjs.org/" target="_blank">
|
||||
NextJS
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="#" target="_blank">
|
||||
Typescript
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://tailwindcss.com/" target="_blank">
|
||||
Tailwind
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://mongodb.com/" target="_blank">
|
||||
MongoDB
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("techWith")}
|
||||
</p>
|
||||
<ul className="mb-4 flex justify-around text-teal-600">
|
||||
<li>
|
||||
<Link href="https://leafletjs.com/" target="_blank">
|
||||
Leaflet
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://nodemailer.com/" target="_blank">
|
||||
Nodemailer
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("contribInfo", {
|
||||
link: (chunks) => (
|
||||
<Link
|
||||
href="https://github.com/TheRealOwenRees/echecsfrance"
|
||||
target="_blank"
|
||||
className="text-teal-600"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="#" target="_blank">
|
||||
Typescript
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://tailwindcss.com/" target="_blank">
|
||||
Tailwind
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://mongodb.com/" target="_blank">
|
||||
MongoDB
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-4 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t("techWith")}
|
||||
</p>
|
||||
<ul className="mb-4 flex justify-around text-teal-600">
|
||||
<li>
|
||||
<Link href="https://leafletjs.com/" target="_blank">
|
||||
Leaflet
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="https://nodemailer.com/" target="_blank">
|
||||
Nodemailer
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-16 text-center font-light text-gray-500 dark:text-gray-400 md:text-xl">
|
||||
{t.rich("contribInfo", {
|
||||
link: (chunks) => (
|
||||
<Link
|
||||
href="https://github.com/TheRealOwenRees/echecsfrance"
|
||||
target="_blank"
|
||||
className="text-teal-600"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</Layout>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMap } from "react-leaflet";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import L from "leaflet";
|
||||
|
||||
const Legend = () => {
|
||||
const t = useTranslations("App");
|
||||
const map = useMap();
|
||||
|
||||
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>
|
||||
<li><span style='background:#00ac39; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
|
||||
"tcClassic"
|
||||
)}</li>
|
||||
<li><span style='background:#0086c7; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
|
||||
"tcRapid"
|
||||
)}</li>
|
||||
<li><span style='background:#cec348; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
|
||||
"tcBlitz"
|
||||
)}</li>
|
||||
<li><span style='background:#d10c3e; display: block; height: 16px; width: 30px; border: 1px solid #999; float: left; margin-right: 5px'></span>${t(
|
||||
"tcKO"
|
||||
)}</li>
|
||||
</ul>`;
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
}
|
||||
}, [map, t]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Legend;
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollableElement } from "@/types";
|
||||
|
||||
import { FaArrowUp } from "react-icons/fa";
|
||||
import { handleScrollToTop } from "@/handlers/scrollHandlers";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const ScrollToTopButton = () => {
|
||||
const scrollToTopElementRef = useRef<ScrollableElement | null>(null);
|
||||
const [isLgScreen, setIsLgScreen] = useState(false);
|
||||
|
||||
// calculate screen size
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsLgScreen(window.innerWidth >= 1024);
|
||||
};
|
||||
handleResize();
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
// 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 top-20 right-3";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${scrollToTopButtonClass} z-10 text-2xl text-teal-900 dark:text-white`}
|
||||
data-test="scroll-to-top-button"
|
||||
>
|
||||
<FaArrowUp
|
||||
onClick={() => handleScrollToTop(scrollToTopElementRef.current)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollToTopButton;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SearchBarProps = {
|
||||
tournamentFilter: string;
|
||||
setTournamentFilter: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const SearchBar = ({
|
||||
tournamentFilter,
|
||||
setTournamentFilter,
|
||||
}: SearchBarProps) => {
|
||||
const t = useTranslations("Tournaments");
|
||||
|
||||
return (
|
||||
<div className="bg-white p-3 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>
|
||||
<input
|
||||
type="text"
|
||||
id="table-search"
|
||||
className="block w-80 rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-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={tournamentFilter}
|
||||
onChange={(e) => setTournamentFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { TournamentDataProps } from "@/types";
|
||||
import { LatLngLiteral } from "leaflet";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
|
||||
import "leaflet-defaulticon-compatibility";
|
||||
|
||||
import { MapContainer, TileLayer, LayersControl } from "react-leaflet";
|
||||
|
||||
import { createLayerGroups } from "@/utils/layerGroups";
|
||||
import Legend from "./Legend";
|
||||
|
||||
export default function TournamentMap({ tournamentData }: TournamentDataProps) {
|
||||
const center: LatLngLiteral = { lat: 47.0844, lng: 2.3964 };
|
||||
|
||||
const classicalMarkers = createLayerGroups("Cadence Lente", "green", {
|
||||
tournamentData,
|
||||
});
|
||||
const rapidMarkers = createLayerGroups("Rapide", "blue", {
|
||||
tournamentData,
|
||||
});
|
||||
const blitzMarkers = createLayerGroups("Blitz", "yellow", {
|
||||
tournamentData,
|
||||
});
|
||||
|
||||
const otherMarkers = createLayerGroups("1h KO", "red", { tournamentData });
|
||||
|
||||
return (
|
||||
<section id="tournament-map" className="grid h-[calc(100vh-144px)]">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={5}
|
||||
scrollWheelZoom={false}
|
||||
style={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Legend />
|
||||
<LayersControl>
|
||||
{classicalMarkers}
|
||||
{rapidMarkers}
|
||||
{blitzMarkers}
|
||||
{otherMarkers}
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { TournamentDataProps } from "@/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import SearchBar from "./SearchBar";
|
||||
import ScrollToTopButton from "./ScrollToTopButton";
|
||||
|
||||
export default function TournamentTable({
|
||||
tournamentData,
|
||||
}: TournamentDataProps) {
|
||||
let tableData;
|
||||
const t = useTranslations("Tournaments");
|
||||
const [searchQuery, setSearchQuery] = useState(""); // text from search bar
|
||||
const [filteredTournamentData, setFilteredTournamentData] =
|
||||
useState(tournamentData);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredTournamentData(
|
||||
tournamentData.filter((t) => t.town.includes(searchQuery.toUpperCase()))
|
||||
);
|
||||
}, [searchQuery, tournamentData]);
|
||||
|
||||
// TODO move this section into its own function
|
||||
if (filteredTournamentData.length === 0) {
|
||||
tableData = (
|
||||
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
|
||||
<td colSpan={4} className="p-3">
|
||||
{t("noneFound")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else {
|
||||
tableData = filteredTournamentData.map((data) => (
|
||||
<tr
|
||||
className="bg-white text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-900"
|
||||
key={data._id}
|
||||
>
|
||||
<td className="p-3">
|
||||
<a href={data.url} target="_blank">
|
||||
{data.date}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<a href={data.url} target="_blank">
|
||||
{data.town}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<a href={data.url} target="_blank">
|
||||
{data.tournament}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<a href={data.url} target="_blank">
|
||||
{data.time_control}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="tournament-table grid w-full auto-rows-max pb-20 lg:col-start-2 lg:col-end-3 lg:h-[calc(100vh-144px)] lg:overflow-y-scroll"
|
||||
id="tournament-table"
|
||||
data-test="tournament-table-div"
|
||||
>
|
||||
<div className="z-10 flex">
|
||||
<SearchBar
|
||||
tournamentFilter={searchQuery}
|
||||
setTournamentFilter={setSearchQuery}
|
||||
/>
|
||||
<div>
|
||||
<ScrollToTopButton />
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
className="relative w-full table-fixed text-center text-xs"
|
||||
data-test="tournament-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky top-0 bg-teal-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("date")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-teal-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("town")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-teal-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("tournament")}
|
||||
</th>
|
||||
<th className="sticky top-0 bg-teal-600 p-3 text-white dark:bg-gray-600">
|
||||
{t("timeControl")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{tableData}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import clientPromise from "@/lib/mongodb";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import Layout from "@/components/Layout";
|
||||
import TournamentTable from "@/components/TournamentTable";
|
||||
import { dateOrderingFrance } from "@/utils/dbDateOrdering";
|
||||
import { errorLog } from "@/utils/logger";
|
||||
|
||||
import TournamentTable from "./TournamentTable";
|
||||
|
||||
export const revalidate = 3600; // revalidate cache every 6 hours;
|
||||
|
||||
const LoadingMap = () => {
|
||||
const t = useTranslations("Competitions");
|
||||
const t = useTranslations("Tournaments");
|
||||
return (
|
||||
<div className="grid h-screen place-items-center bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
|
||||
<p>{t("loading")}</p>
|
||||
@@ -22,7 +22,7 @@ const LoadingMap = () => {
|
||||
* Imports the tournament map component, ensuring CSR only.
|
||||
* @remarks SSR is not supported by react-leaflet
|
||||
*/
|
||||
const TournamentMap = dynamic(() => import("@/components/TournamentMap"), {
|
||||
const TournamentMap = dynamic(() => import("./TournamentMap"), {
|
||||
ssr: false,
|
||||
loading: LoadingMap,
|
||||
});
|
||||
@@ -43,15 +43,13 @@ export default async function Tournaments() {
|
||||
const tournamentData = await getTournaments();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<main className="relative grid lg:grid-cols-2">
|
||||
<div className="">
|
||||
<TournamentMap tournamentData={JSON.parse(tournamentData)} />
|
||||
</div>
|
||||
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
|
||||
<TournamentTable tournamentData={JSON.parse(tournamentData)} />
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
<main className="relative grid h-full lg:grid-cols-2">
|
||||
<div className="">
|
||||
<TournamentMap tournamentData={JSON.parse(tournamentData)} />
|
||||
</div>
|
||||
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
|
||||
<TournamentTable tournamentData={JSON.parse(tournamentData)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user