From 7b29fc91ad8f127083d98a1f768c84557e59116f Mon Sep 17 00:00:00 2001 From: Dzmitry Yaniuk <101649138+Dzmitry-Yaniuk@users.noreply.github.com> Date: Fri, 27 Dec 2024 23:33:08 +0300 Subject: [PATCH] EPMGCIP-180: Decrease quantity of warnings to 0 (#36) * EPMGCIP-180: Add configuration to ESLint to extend "react/recommended" plugin * EPMGCIP-180: Resolve ESLint warnings in "i18n.ts" file * EPMGCIP-180: Resolve ESLint warnings in "getExhibit" function * EPMGCIP-180: Resolve ESLint warnings in "sendContactForm" function * EPMGCIP-180: Resolve ESLint warnings related to "IExhibit" interface * EPMGCIP-180: Split "Exhibit" page into separate subcomponents, update usages * EPMGCIP-180: Suppress complexity issue on "ExhibitDetails" due to simple structure * EPMGCIP-180: Ignore blank lines and comments in "max-lines" ESLint rules * EPMGCIP-180: Resolve ESLint warnings related to "ImageGallery" component * EPMGCIP-180: Decrease max warnings level in Git hooks to 0 allowed --- .eslintrc.json | 4 +- lint-staged.config.mjs | 2 +- .../send-contact-form/sendContactForm.ts | 4 +- src/app/[locale]/exhibit/[slug]/page.tsx | 3 +- .../organisms/ImageGallery/ImageGallery.tsx | 96 ++++++------ src/components/pages/Exhibit/Exhibit.tsx | 141 +++++------------- .../pages/Exhibit/ExhibitDetails.tsx | 69 +++++++++ .../pages/Exhibit/ExhibitNotFoundMessage.tsx | 41 +++++ src/i18n/request.ts | 6 +- src/interfaces/IExhibit.ts | 10 +- src/lib/exhibit.ts | 7 +- 11 files changed, 224 insertions(+), 159 deletions(-) create mode 100644 src/components/pages/Exhibit/ExhibitDetails.tsx create mode 100644 src/components/pages/Exhibit/ExhibitNotFoundMessage.tsx diff --git a/.eslintrc.json b/.eslintrc.json index af93f3b..f949133 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,6 +6,7 @@ "plugin:prettier/recommended", "plugin:@typescript-eslint/recommended", "plugin:jest/recommended", + "plugin:react/recommended", "plugin:react-hooks/recommended" ], "ignorePatterns": ["node_modules", "src/__generated__", "build", "coverage"], @@ -75,7 +76,7 @@ } ], "max-depth": ["warn", 2], - "max-lines": ["warn", 200], + "max-lines": ["warn", { "max": 200, "skipBlankLines": true, "skipComments": true }], "max-nested-callbacks": ["warn", 3], "max-params": ["warn", 3], "max-statements-per-line": [ @@ -110,6 +111,7 @@ "no-warning-comments": "warn", "one-var-declaration-per-line": ["warn", "always"], "react/display-name": "warn", + "react/react-in-jsx-scope": "off", "react/sort-comp": "warn", "sort-keys": "warn", "unused-imports/no-unused-imports": "error" diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 5ba0fd5..890f5d1 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -1,5 +1,5 @@ const config = { - '*.{js,jsx,ts,tsx}': ['eslint --fix --max-warnings 20', 'prettier --write'], + '*.{js,jsx,ts,tsx}': ['eslint --fix --max-warnings 0', 'prettier --write'], '*.{json,scss,css}': ['prettier --write'], }; diff --git a/src/actions/send-contact-form/sendContactForm.ts b/src/actions/send-contact-form/sendContactForm.ts index 20f943e..3339ca9 100644 --- a/src/actions/send-contact-form/sendContactForm.ts +++ b/src/actions/send-contact-form/sendContactForm.ts @@ -31,8 +31,6 @@ export async function sendContactForm({ email, message, name, subject }: SendEma await transporter.sendMail({ from: name, - subject, - to: process.env.CONTACTS_EMAIL, html: `

Name: ${name}

@@ -44,6 +42,8 @@ export async function sendContactForm({ email, message, name, subject }: SendEma

${message}

`, + subject, + to: process.env.CONTACTS_EMAIL, }); return { success: true }; diff --git a/src/app/[locale]/exhibit/[slug]/page.tsx b/src/app/[locale]/exhibit/[slug]/page.tsx index 01e7a0b..fba75dd 100644 --- a/src/app/[locale]/exhibit/[slug]/page.tsx +++ b/src/app/[locale]/exhibit/[slug]/page.tsx @@ -1,6 +1,5 @@ import Exhibit from '@/components/pages/Exhibit/Exhibit'; - -import { getExhibit } from '../../../../lib/exhibit'; +import { getExhibit } from '@/lib/exhibit'; interface Props { params: { diff --git a/src/components/organisms/ImageGallery/ImageGallery.tsx b/src/components/organisms/ImageGallery/ImageGallery.tsx index 8dbddbb..e035049 100644 --- a/src/components/organisms/ImageGallery/ImageGallery.tsx +++ b/src/components/organisms/ImageGallery/ImageGallery.tsx @@ -28,7 +28,7 @@ interface Props { isLinkImage: boolean; } -function ImageGallery({ images, displayArrows, isLinkImage }: Props) { +export default function ImageGallery({ images, displayArrows, isLinkImage }: Props) { const zoomRef = useRef(null); const { id: galleryId, @@ -117,8 +117,8 @@ function ImageGallery({ images, displayArrows, isLinkImage }: Props) { ) => { const { innerWidth, innerHeight } = window; const aspectRatio = image.width / image.height; - let wrapperX = 0; - let wrapperY = 0; + let wrapperX: number; + let wrapperY: number; if (image.height > window.innerHeight && image.width < window.innerWidth) { wrapperY = Math.min(innerHeight - padding * 2, image.height); @@ -131,19 +131,57 @@ function ImageGallery({ images, displayArrows, isLinkImage }: Props) { return { wrapperX, wrapperY }; }; - function calculateTranslationOffsets( - wrapperX: number, - wrapperY: number, - translateX: number, - translateY: number, - zoom: number, - ) { + const calculateTranslationOffsets = ({ + wrapperX, + wrapperY, + translateX, + translateY, + zoom, + }: { + wrapperX: number; + wrapperY: number; + translateX: number; + translateY: number; + zoom: number; + }) => { + const wrapperOffset = 100; + const translateOffset = 50; + const yetAnotherReactLightboxShift = 1 - 1 / zoom; - const offsetX = ((wrapperX / 100) * (translateX - 50)) / yetAnotherReactLightboxShift; - const offsetY = ((wrapperY / 100) * (translateY - 50)) / yetAnotherReactLightboxShift; + const offsetX = + ((wrapperX / wrapperOffset) * (translateX - translateOffset)) / yetAnotherReactLightboxShift; + const offsetY = + ((wrapperY / wrapperOffset) * (translateY - translateOffset)) / yetAnotherReactLightboxShift; return { offsetX, offsetY }; - } + }; + + const onEnterLightbox = async (): Promise => { + if (!isOpeningWithZoom) { + const imageUrl = images.find((image) => image.id === galleryId)?.url || ''; + + if (!imageUrl) { + console.error('Image URL not found.'); + + return; + } + + const currentImage = await loadImage(imageUrl); + const { wrapperX, wrapperY } = determineWrapperDimensions(currentImage, carouselPadding); + const { offsetX, offsetY } = calculateTranslationOffsets({ + translateX: zoomOffsetX, + translateY: zoomOffsetY, + wrapperX, + wrapperY, + zoom: zoomValue, + }); + + zoomRef.current?.changeZoom(zoomValue, true, offsetX, offsetY); + } + + setZoom(0, 0, 0); + setIsOpeningWithZoom(false); + }; return ( <> @@ -180,39 +218,9 @@ function ImageGallery({ images, displayArrows, isLinkImage }: Props) { carousel={{ padding: carouselPadding, }} - on={{ - entered: async () => { - if (isOpeningWithZoom) { - const imageUrl = images.find((image) => image.id === galleryId)?.url || ''; - if (!imageUrl) { - console.error('Image URL not found.'); - - return; - } - - const currentImage = await loadImage(imageUrl); - const { wrapperX, wrapperY } = determineWrapperDimensions( - currentImage, - carouselPadding, - ); - const { offsetX, offsetY } = calculateTranslationOffsets( - wrapperX, - wrapperY, - zoomOffsetX, - zoomOffsetY, - zoomValue, - ); - - zoomRef.current?.changeZoom(zoomValue, true, offsetX, offsetY); - } - setZoom(0, 0, 0); - setIsOpeningWithZoom(false); - }, - }} + on={{ entered: onEnterLightbox }} className={clsx(isOpeningWithZoom && 'image-gallery-zooming')} /> ); } - -export default ImageGallery; diff --git a/src/components/pages/Exhibit/Exhibit.tsx b/src/components/pages/Exhibit/Exhibit.tsx index 1595571..34889cf 100644 --- a/src/components/pages/Exhibit/Exhibit.tsx +++ b/src/components/pages/Exhibit/Exhibit.tsx @@ -2,19 +2,17 @@ import { useEffect, useState } from 'react'; -import { documentToReactComponents } from '@contentful/rich-text-react-renderer'; -import { useLocale, useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; import { useShallow } from 'zustand/react/shallow'; import IExhibit from '@/interfaces/IExhibit'; -import { LocaleCodeCamelcase, localesCamelcase } from '@/locales'; +import { LocaleCodeCamelcase } from '@/locales'; import capitalizeFirstLetter from '@/utils/capitalizeFirstLetter'; -import styles from './Exhibit.module.scss'; +import { ExhibitDetails } from './ExhibitDetails'; +import { ExhibitNotFoundMessage } from './ExhibitNotFoundMessage'; import triggerGtagVisit from '../../../gtag/visit'; import useImageGalleryStore from '../../../stores/useImageGalleryStore'; -import ImageGallery from '../../organisms/ImageGallery/ImageGallery'; -import Player from '../../organisms/Player/Player'; interface Props { exhibit: IExhibit; @@ -35,114 +33,57 @@ export default function ExhibitPage({ exhibit, slug }: Props) { setZoom: state.setZoom, })), ); - const t = useTranslations(); const locale = capitalizeFirstLetter(useLocale()) as LocaleCodeCamelcase; const [currentLocale, setCurrentLocale] = useState(locale); - const images = - exhibit?.imagesCollection?.items?.map((i) => ({ - id: i?.sys.id || '', - url: i?.url || '', - })) || []; - - const title = exhibit?.[`name${currentLocale}`]; - const author = exhibit?.[`author${currentLocale}`]; - const audioFile = exhibit?.[`audioFile${currentLocale}`]?.url; - const description = exhibit?.[`description${currentLocale}`]?.json; - useEffect(() => { if (exhibit?.sys.id && slug && currentLocale) { triggerGtagVisit(exhibit.sys.id, currentLocale, slug); } }, [exhibit?.sys.id, slug, currentLocale]); - useEffect(() => { + useEffect((): void => { + const initGalleryZoomWithSelectedImage = (href: string): void => { + const params = new URLSearchParams(new URL(href).search); + const idString = params.get('imageId') || ''; + const xString = params.get('x'); + const yString = params.get('y'); + const zoomString = params.get('zoom'); + const x = xString ? parseInt(xString, 10) : 0; + const y = yString ? parseInt(yString, 10) : 0; + const zoom = zoomString ? parseInt(zoomString, 10) : 0; + + setIsOpeningGalleryWithZoom(true); + setGalleryId(idString); + setGalleryZoom(zoom, x, y); + setIsOpenGallery(true); + }; + + const clickEventListener = (e: MouseEvent): void => { + const { href } = e?.target as HTMLAnchorElement; + + if (!href.includes('imageId')) { + return; + } + + e.preventDefault(); + + initGalleryZoomWithSelectedImage(href); + }; + document .getElementById('exhibit-description') ?.querySelectorAll('a') .forEach((link) => { - link.addEventListener('click', (e) => { - const { href } = e?.target as HTMLAnchorElement; - if (!href.includes('imageId')) { - return; - } - - e.preventDefault(); - - const params = new URLSearchParams(new URL(href).search); - const idString = params.get('imageId') || ''; - const xString = params.get('x'); - const yString = params.get('y'); - const zoomString = params.get('zoom'); - const x = xString ? parseInt(xString, 10) : 0; - const y = yString ? parseInt(yString, 10) : 0; - const zoom = zoomString ? parseInt(zoomString, 10) : 0; - - setIsOpeningGalleryWithZoom(true); - setGalleryId(idString); - setGalleryZoom(zoom, x, y); - setIsOpenGallery(true); - }); + link.addEventListener('click', clickEventListener); }); }); - const handleLinkOnClick = (lang: LocaleCodeCamelcase) => { - setCurrentLocale(lang); - }; - - const links = localesCamelcase - .filter((lang) => exhibit?.[`name${lang}`]) - .map((lang) => ( - - {' '} - - )); - - return ( - <> - {!title && ( -
- {t('exhibitNotFoundForLanguage')} {links} -
- )} - - {title && ( -
-

{title}

- - {author &&
{author}
} - - {images.length > 0 && ( -
- -
- )} - - {audioFile && ( -
- -
- )} - - {exhibit?.yearOfCreation && ( -
-
{t('date')}
-
{exhibit?.yearOfCreation}
-
- )} - - {description && ( -
-
{t('description')}
-
- {documentToReactComponents(description)} -
-
- )} -
- )} - - ); + const title = exhibit[`name${currentLocale}`]; + + if (!title) { + return ; + } + + return ; } diff --git a/src/components/pages/Exhibit/ExhibitDetails.tsx b/src/components/pages/Exhibit/ExhibitDetails.tsx new file mode 100644 index 0000000..7eb2082 --- /dev/null +++ b/src/components/pages/Exhibit/ExhibitDetails.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { documentToReactComponents } from '@contentful/rich-text-react-renderer'; +import { useTranslations } from 'next-intl'; + +import ImageGallery from '@/components/organisms/ImageGallery/ImageGallery'; +import Player from '@/components/organisms/Player/Player'; +import IExhibit from '@/interfaces/IExhibit'; +import { LocaleCodeCamelcase } from '@/locales'; + +import styles from './Exhibit.module.scss'; + +interface Props { + exhibit: IExhibit; + selectedLocale: LocaleCodeCamelcase; +} + +// eslint-disable-next-line complexity +export const ExhibitDetails: React.FC = (props) => { + const t = useTranslations(); + + const title = props.exhibit[`name${props.selectedLocale}`]; + const author = props.exhibit[`author${props.selectedLocale}`]; + const audioFile = props.exhibit[`audioFile${props.selectedLocale}`]?.url; + const description = props.exhibit[`description${props.selectedLocale}`]?.json; + const yearOfCreation = props.exhibit.yearOfCreation; + + const images = + props.exhibit.imagesCollection?.items?.map((i) => ({ + id: i?.sys.id || '', + url: i?.url || '', + })) ?? []; + + return ( +
+

{title}

+ + {author &&
{author}
} + + {images.length > 0 && ( +
+ +
+ )} + + {audioFile && ( +
+ +
+ )} + + {yearOfCreation && ( +
+
{t('date')}
+
{yearOfCreation}
+
+ )} + + {description && ( +
+
{t('description')}
+
+ {documentToReactComponents(description)} +
+
+ )} +
+ ); +}; diff --git a/src/components/pages/Exhibit/ExhibitNotFoundMessage.tsx b/src/components/pages/Exhibit/ExhibitNotFoundMessage.tsx new file mode 100644 index 0000000..a3bc99c --- /dev/null +++ b/src/components/pages/Exhibit/ExhibitNotFoundMessage.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { useTranslations } from 'next-intl'; + +import IExhibit from '@/interfaces/IExhibit'; +import { LocaleCodeCamelcase, localesCamelcase } from '@/locales'; + +import styles from './Exhibit.module.scss'; + +interface Props { + exhibit: IExhibit; + setLocale: (locale: LocaleCodeCamelcase) => void; +} + +export const ExhibitNotFoundMessage: React.FC = (props) => { + const t = useTranslations(); + + const handleLinkOnClick = + (lang: LocaleCodeCamelcase) => + (e: React.SyntheticEvent): void => { + e.preventDefault(); + + props.setLocale(lang); + }; + + const links = localesCamelcase + .filter((lang) => props.exhibit?.[`name${lang}`]) + .map((lang) => ( + + {' '} + + )); + + return ( +
+ {t('exhibitNotFoundForLanguage')} {links} +
+ ); +}; diff --git a/src/i18n/request.ts b/src/i18n/request.ts index e84a46d..862a1bd 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,13 +1,13 @@ import { getRequestConfig } from 'next-intl/server'; -import { locales } from '@/locales'; +import { LocaleCode, locales } from '@/locales'; import { routing } from './routing'; export default getRequestConfig(async ({ requestLocale }) => { - let locale = await requestLocale; + let locale = (await requestLocale) as LocaleCode; - if (!locale || !locales.includes(locale as any)) { + if (!locale || !locales.includes(locale)) { locale = routing.defaultLocale; } diff --git a/src/interfaces/IExhibit.ts b/src/interfaces/IExhibit.ts index ce2cf9e..11f863c 100644 --- a/src/interfaces/IExhibit.ts +++ b/src/interfaces/IExhibit.ts @@ -1,3 +1,5 @@ +import { Document } from '@contentful/rich-text-types'; + export default interface IExhibit { sys: { id: string; @@ -12,16 +14,16 @@ export default interface IExhibit { authorKa?: string | null; yearOfCreation?: string | null; descriptionEn?: { - json: any; + json: undefined | Document; } | null; descriptionRu?: { - json: any; + json: undefined | Document; } | null; descriptionUz?: { - json: any; + json: undefined | Document; } | null; descriptionKa?: { - json: any; + json: undefined | Document; } | null; imagesCollection?: { items: Array<{ diff --git a/src/lib/exhibit.ts b/src/lib/exhibit.ts index c0b86b7..4e7edfd 100644 --- a/src/lib/exhibit.ts +++ b/src/lib/exhibit.ts @@ -1,19 +1,22 @@ +import IExhibit from '@/interfaces/IExhibit'; import { IPreviewExhibit } from '@/interfaces/IPreviewExhibit'; import { getClient } from './ApolloClient'; import { GetExhibitDocument, GetTopLatestExhibitsDocument } from '../__generated__/graphql'; -export async function getExhibit(slug: string) { +export async function getExhibit(slug: string): Promise { try { const { data } = await getClient().query({ query: GetExhibitDocument, variables: { slug }, }); - return data.exhibitCollection?.items[0]; + return data.exhibitCollection?.items[0] as IExhibit; } catch (error) { console.error('Failed to fetch exhibit', error); } + + return undefined; } export async function getTopLatestExhibits(limit: number): Promise {