/* eslint-disable max-statements */ import NextImage from 'next/image'; import { useRouter } from 'next/router'; import Script from 'next/script'; import { type FC, type ReactElement, type ReactNode, useRef, type CSSProperties, type FormEvent, useCallback, } from 'react'; import { useIntl } from 'react-intl'; import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts'; import type { NextPageWithLayoutOptions } from '../../../types'; import { CONFIG } from '../../../utils/config'; import { ROUTES } from '../../../utils/constants'; import { useAutofocus, useBoolean, useOnRouteChange, useScrollPosition, } from '../../../utils/hooks'; import { ButtonLink, Footer, Header, Heading, Icon, Logo, Main, } from '../../atoms'; import { BackToTop, Branding, Colophon, type ColophonLink, Copyright, FlippingLogo, } from '../../molecules'; import { type MainNavItem, Navbar, MainNav, SearchForm, SettingsForm, type NavbarItems, type SearchFormSubmit, } from '../../organisms'; import styles from './layout.module.scss'; export type QueryAction = SearchAction & { 'query-input': string; }; export type LayoutProps = { /** * The layout main content. */ children: ReactNode; /** * Is it homepage? * * @default false */ isHome?: boolean; }; /** * Layout component * * Render the base layout used by all pages. */ export const Layout: FC = ({ children, isHome }) => { const router = useRouter(); const intl = useIntl(); const { baseline, copyright, locales, name, url } = CONFIG; const skipToContent = intl.formatMessage({ defaultMessage: 'Skip to content', description: 'Layout: Skip to content link', id: 'K4rYdT', }); const noScript = intl.formatMessage({ defaultMessage: 'Warning: If you want to benefit from all features (search for example), please activate Javascript.', description: 'Layout: noscript message', id: '7jVUT6', }); const copyrightTitle = intl.formatMessage({ defaultMessage: 'CC BY SA', description: 'Layout: copyright title', id: 'yB1SPF', }); const homeLabel = intl.formatMessage({ defaultMessage: 'Home', description: 'Layout: main nav - home link', id: 'bojYF5', }); const blogLabel = intl.formatMessage({ defaultMessage: 'Blog', description: 'Layout: main nav - blog link', id: 'D8vB38', }); const projectsLabel = intl.formatMessage({ defaultMessage: 'Projects', description: 'Layout: main nav - projects link', id: 'qnwsWV', }); const cvLabel = intl.formatMessage({ defaultMessage: 'CV', description: 'Layout: main nav - cv link', id: 'R895yC', }); const contactLabel = intl.formatMessage({ defaultMessage: 'Contact', description: 'Layout: main nav - contact link', id: 'AE4kCD', }); const photoAltText = intl.formatMessage( { defaultMessage: '{website} picture', description: 'Layout: photo alternative text', id: '8jjY1X', }, { website: name } ); const logoTitle = intl.formatMessage( { defaultMessage: '{website} logo', description: 'Layout: logo title', id: '52H2HA', }, { website: name } ); const backToTop = intl.formatMessage({ defaultMessage: 'Back to top', description: 'Layout: an accessible name for the back to top button', id: 'Kjj1Zk', }); const mainNav: MainNavItem[] = [ { id: 'home', label: homeLabel, href: '/', logo: , }, { id: 'blog', label: blogLabel, href: ROUTES.BLOG, logo: , }, { id: 'projects', label: projectsLabel, href: ROUTES.PROJECTS, logo: , }, { id: 'cv', label: cvLabel, href: ROUTES.CV, logo: , }, { id: 'contact', label: contactLabel, href: ROUTES.CONTACT, logo: , }, ]; const { deactivate: deactivateMainNav, state: isMainNavOpen, toggle: toggleMainNav, } = useBoolean(false); const { deactivate: deactivateSearch, state: isSearchOpen, toggle: toggleSearch, } = useBoolean(false); const { deactivate: deactivateSettings, state: isSettingsOpen, toggle: toggleSettings, } = useBoolean(false); const labels = { mainNavItem: intl.formatMessage({ defaultMessage: 'Open menu', description: 'Layout: main nav button label in navbar', id: 'Fgt/RZ', }), mainNavModal: intl.formatMessage({ defaultMessage: 'Main navigation', description: 'Layout: main nav accessible name', id: 'dfTljv', }), searchItem: intl.formatMessage({ defaultMessage: 'Open search', id: 'XRwEoA', description: 'Layout: search button label in navbar', }), searchModal: intl.formatMessage({ defaultMessage: 'Search', description: 'Layout: search modal title in navbar', id: 'Mq+O6q', }), settingsItem: intl.formatMessage({ defaultMessage: 'Open settings', id: 'mDKiaN', description: 'Layout: settings button label in navbar', }), settingsForm: intl.formatMessage({ defaultMessage: 'Settings form', id: 'h3J0a+', description: 'Layout: an accessible name for the settings form in navbar', }), settingsModal: intl.formatMessage({ defaultMessage: 'Settings', description: 'Layout: settings modal title in navbar', id: 'o3WSz5', }), }; const settingsSubmitHandler = useCallback((e: FormEvent) => { e.preventDefault(); }, []); const searchInputRef = useAutofocus({ condition: () => isSearchOpen, delay: 360, }); const searchSubmitHandler: SearchFormSubmit = useCallback( ({ query }) => { if (!query) return { messages: { error: intl.formatMessage({ defaultMessage: 'Query must be longer than one character.', description: 'Layout: invalid query message', id: 'C2YcUJ', }), }, validator: (value) => value.query.length > 1, }; router.push({ pathname: ROUTES.SEARCH, query: { s: query } }); return undefined; }, [intl, router] ); useOnRouteChange(deactivateSearch); const navbarItems: NavbarItems = [ { contents: , icon: 'hamburger', id: 'main-nav', isActive: isMainNavOpen, label: labels.mainNavItem, modalVisibleFrom: 'md', onDeactivate: deactivateMainNav, onToggle: toggleMainNav, }, { contents: ( ), icon: 'magnifying-glass', id: 'search', isActive: isSearchOpen, label: labels.searchItem, onDeactivate: deactivateSearch, onToggle: toggleSearch, modalHeading: labels.searchModal, }, { contents: ( ), icon: 'cog', id: 'settings', isActive: isSettingsOpen, label: labels.settingsItem, onDeactivate: deactivateSettings, onToggle: toggleSettings, modalHeading: labels.settingsModal, showIconOnModal: true, }, ]; const legalNoticeLabel = intl.formatMessage({ defaultMessage: 'Legal notice', description: 'Layout: Legal notice label', id: 'nwbzKm', }); const footerNav: ColophonLink[] = [ { id: 'legal-notice', label: legalNoticeLabel, href: ROUTES.LEGAL_NOTICE }, ]; const searchActionSchema: QueryAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${url}${ROUTES.SEARCH}?s={search_term_string}`, }, query: 'required', 'query-input': 'required name=search_term_string', }; const schemaJsonLd: WithContext = { '@context': 'https://schema.org', '@id': `${url}`, '@type': 'WebSite', name, description: baseline, url, author: { '@id': `${url}/#branding` }, copyrightYear: Number(copyright.startYear), creator: { '@id': `${url}/#branding` }, editor: { '@id': `${url}/#branding` }, inLanguage: locales.defaultLocale, potentialAction: searchActionSchema, }; const brandingSchema: WithContext = { '@context': 'https://schema.org', '@type': 'Person', '@id': `${url}/#branding`, name, url, jobTitle: baseline, image: '/armand-philippot.jpg', subjectOf: { '@id': `${url}` }, }; const scrollPos = useScrollPosition(); const backToTopBreakpoint = 300; const backToTopClassName = [ styles['back-to-top'], styles[ scrollPos.y > backToTopBreakpoint ? 'back-to-top--visible' : 'back-to-top--hidden' ], ].join(' '); const topRef = useRef(null); const giveFocusToTopRef = () => { if (topRef.current) topRef.current.focus(); }; useOnRouteChange(giveFocusToTopRef); const brandingTitleStyles = { '--typing-animation': 'blink 0.7s ease-in-out 0s 2, typing 4.3s linear 0s 1', } as CSSProperties; const brandingBaselineStyles = { '--typing-animation': 'hide-text 4.25s linear 0s 1, blink 0.8s ease-in-out 4.25s 2, typing 3.8s linear 4.25s 1', } as CSSProperties; return ( <>