From 1cda44defdbd85b273149b1afaab5c5cb944e940 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 13 Nov 2023 09:37:49 +0200 Subject: [PATCH 01/64] refactor: adjust animations --- src/features/MovieList/ui/PageNum.tsx | 7 +++---- src/shared/ui/Tabs.tsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/features/MovieList/ui/PageNum.tsx b/src/features/MovieList/ui/PageNum.tsx index f5c6723..b0cf982 100644 --- a/src/features/MovieList/ui/PageNum.tsx +++ b/src/features/MovieList/ui/PageNum.tsx @@ -9,16 +9,16 @@ function PageNum() { const { readUrl } = useUrl(); const { totalResults } = useSearch(); const moviesPerPage = Number(readUrl(urlParams.MOVIES_PER_PAGE)); - const prevCurrPage = useRef(0); const prevMaxPage = useRef(0); const currPage = Number(readUrl(urlParams.PAGE)); const maxPage = Math.ceil(totalResults / moviesPerPage); const currPageRef = useAnime({ - textContent: [prevCurrPage.current, currPage], + textContent: [0, currPage], round: 1, easing: 'easeInOutExpo', + delay: 500, }); const maxPageRef = useAnime( @@ -39,9 +39,8 @@ function PageNum() { }); useEffect(() => { - prevCurrPage.current = currPage; prevMaxPage.current = maxPage; - }, [currPage, maxPage]); + }, [maxPage]); return ( ({ style={{ viewTransitionName: `tab-${activeValue}`, }} - className="relative w-fit rounded-full border-l border-t border-white/20 bg-white/10 p-1 text-zinc-100 shadow-2xl backdrop-blur-md backdrop-saturate-200"> + className="relative w-fit overflow-hidden rounded-full border-l border-t border-white/20 bg-white/10 p-1 text-zinc-100 shadow-2xl backdrop-blur-md backdrop-saturate-200"> Date: Mon, 13 Nov 2023 15:26:28 +0200 Subject: [PATCH 02/64] refactor: minor code refactor --- src/features/MovieList/ui/PageNum.tsx | 5 +++-- src/shared/hooks/useAnime.tsx | 18 +++++++++--------- src/shared/ui/Tabs.tsx | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/features/MovieList/ui/PageNum.tsx b/src/features/MovieList/ui/PageNum.tsx index b0cf982..13b91fa 100644 --- a/src/features/MovieList/ui/PageNum.tsx +++ b/src/features/MovieList/ui/PageNum.tsx @@ -14,14 +14,14 @@ function PageNum() { const currPage = Number(readUrl(urlParams.PAGE)); const maxPage = Math.ceil(totalResults / moviesPerPage); - const currPageRef = useAnime({ + const currPageRef = useAnime({ textContent: [0, currPage], round: 1, easing: 'easeInOutExpo', delay: 500, }); - const maxPageRef = useAnime( + const maxPageRef = useAnime( { textContent: [prevMaxPage.current, maxPage], round: 1, @@ -31,6 +31,7 @@ function PageNum() { ); const containerRef = useAnime({ + targets: '', scale: [0, 1], opacity: [0, 1], easing: 'easeInOutElastic(1, .34)', diff --git a/src/shared/hooks/useAnime.tsx b/src/shared/hooks/useAnime.tsx index dce18b3..3e8f80d 100644 --- a/src/shared/hooks/useAnime.tsx +++ b/src/shared/hooks/useAnime.tsx @@ -5,28 +5,28 @@ import { DependencyList, useEffect, useRef } from 'react'; import { AnimeParams } from 'animejs'; import anime from 'animejs/lib/anime.es.js'; -type Params = Omit; +type AnimeTarget = HTMLElement | SVGElement | NodeList | null; /** * Applies anime.js animation to the element using the specified parameters and dependencies. * * @template TElem - The type of the HTML element to animate. - * @param {Params} params - The parameters used to configure the animation. + * @param {AnimeParams} params - The parameters used to configure the animation. * @param {DependencyList} [deps=[]] - The list of dependencies that trigger the animation when changed. * @return {React.MutableRefObject} The reference to the HTML element being animated. + * You can also pass the targets param to params object, if your component already have ref to an element that needs to be animated */ -function useAnime( - params: Params, - deps: DependencyList = [], -) { - const elementRef = useRef(null); +function useAnime< + T extends AnimeTarget | ReadonlyArray | undefined, +>(params: AnimeParams, deps: DependencyList = []) { + const elementRef = useRef(null); useEffect(() => { anime({ - targets: elementRef.current, + targets: elementRef.current || params?.targets, ...params, }); - }, [...deps]); + }, [...deps, params.targets]); return elementRef; } diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx index 2aac746..02c9e49 100644 --- a/src/shared/ui/Tabs.tsx +++ b/src/shared/ui/Tabs.tsx @@ -92,7 +92,7 @@ function Tabs({ data-animate="tabs" ref={animateContainerRef} style={{ - viewTransitionName: `tab-${activeValue}`, + viewTransitionName: 'tabs', }} className="relative w-fit overflow-hidden rounded-full border-l border-t border-white/20 bg-white/10 p-1 text-zinc-100 shadow-2xl backdrop-blur-md backdrop-saturate-200"> From 6f0ae6c02532f2c6f308276f8ab3ad1662532b80 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 13 Nov 2023 23:50:39 +0200 Subject: [PATCH 03/64] refactor: change useAnime hook api --- src/features/MovieList/ui/PageNum.tsx | 9 ++--- src/shared/hooks/useAnime.ts | 54 +++++++++++++++++++++++++++ src/shared/hooks/useAnime.tsx | 34 ----------------- src/shared/ui/Tabs.tsx | 6 +-- 4 files changed, 61 insertions(+), 42 deletions(-) create mode 100644 src/shared/hooks/useAnime.ts delete mode 100644 src/shared/hooks/useAnime.tsx diff --git a/src/features/MovieList/ui/PageNum.tsx b/src/features/MovieList/ui/PageNum.tsx index 13b91fa..b624b65 100644 --- a/src/features/MovieList/ui/PageNum.tsx +++ b/src/features/MovieList/ui/PageNum.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; -import useAnime from '../../../shared/hooks/useAnime.tsx'; +import useAnime from '../../../shared/hooks/useAnime.ts'; import useUrl from '../../../shared/hooks/useUrl.ts'; import { urlParams } from '../../../shared/types/enums.ts'; import useSearch from '../../Search/hooks/useSearch.ts'; @@ -14,14 +14,14 @@ function PageNum() { const currPage = Number(readUrl(urlParams.PAGE)); const maxPage = Math.ceil(totalResults / moviesPerPage); - const currPageRef = useAnime({ + const { elementRef: currPageRef } = useAnime({ textContent: [0, currPage], round: 1, easing: 'easeInOutExpo', delay: 500, }); - const maxPageRef = useAnime( + const { elementRef: maxPageRef } = useAnime( { textContent: [prevMaxPage.current, maxPage], round: 1, @@ -30,8 +30,7 @@ function PageNum() { [maxPage], ); - const containerRef = useAnime({ - targets: '', + const { elementRef: containerRef } = useAnime({ scale: [0, 1], opacity: [0, 1], easing: 'easeInOutElastic(1, .34)', diff --git a/src/shared/hooks/useAnime.ts b/src/shared/hooks/useAnime.ts new file mode 100644 index 0000000..7dc4b84 --- /dev/null +++ b/src/shared/hooks/useAnime.ts @@ -0,0 +1,54 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { DependencyList, RefObject, useEffect, useRef } from 'react'; + +import { AnimeInstance, AnimeParams } from 'animejs'; +import anime from 'animejs/lib/anime.es.js'; + +type AnimeTarget = + | HTMLElement + | SVGElement + | NodeList + | RefObject + | null; + +interface IParams extends Omit { + targets?: AnimeTarget; +} + +/** + * Applies anime.js animation to the element using the specified parameters and dependencies. + * + * @template TElem - The type of the HTML element to animate. + * @param {AnimeParams} params - The parameters used to configure the animation. + * @param {DependencyList} [deps=[]] - The list of dependencies that trigger the animation when changed. + * @return {React.MutableRefObject} The reference to the HTML element being animated. + * You can also pass the targets param to params object, if your component already have ref to an element that needs to be animated + */ +function useAnime( + params: IParams, + deps: DependencyList = [], +) { + const elementRef = useRef(null); + const animeRef = useRef(); + + useEffect(() => { + if (params.targets && 'current' in params.targets) { + animeRef.current = anime({ + ...params, + targets: params.targets.current, + }); + } + + if (!params.targets || !('current' in params.targets)) { + animeRef.current = anime({ + ...params, + targets: elementRef.current, + }); + } + }, [...deps]); + + return { elementRef, animation: animeRef }; +} + +export default useAnime; diff --git a/src/shared/hooks/useAnime.tsx b/src/shared/hooks/useAnime.tsx deleted file mode 100644 index 3e8f80d..0000000 --- a/src/shared/hooks/useAnime.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ - -import { DependencyList, useEffect, useRef } from 'react'; - -import { AnimeParams } from 'animejs'; -import anime from 'animejs/lib/anime.es.js'; - -type AnimeTarget = HTMLElement | SVGElement | NodeList | null; - -/** - * Applies anime.js animation to the element using the specified parameters and dependencies. - * - * @template TElem - The type of the HTML element to animate. - * @param {AnimeParams} params - The parameters used to configure the animation. - * @param {DependencyList} [deps=[]] - The list of dependencies that trigger the animation when changed. - * @return {React.MutableRefObject} The reference to the HTML element being animated. - * You can also pass the targets param to params object, if your component already have ref to an element that needs to be animated - */ -function useAnime< - T extends AnimeTarget | ReadonlyArray | undefined, ->(params: AnimeParams, deps: DependencyList = []) { - const elementRef = useRef(null); - - useEffect(() => { - anime({ - targets: elementRef.current || params?.targets, - ...params, - }); - }, [...deps, params.targets]); - - return elementRef; -} - -export default useAnime; diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx index 02c9e49..5e2b2a2 100644 --- a/src/shared/ui/Tabs.tsx +++ b/src/shared/ui/Tabs.tsx @@ -11,7 +11,7 @@ import { } from 'react'; import Button from './Button.tsx'; -import useAnime from '../hooks/useAnime.tsx'; +import useAnime from '../hooks/useAnime.ts'; import { itemsPerPage } from '../types/enums.ts'; interface IOptionProps { @@ -67,14 +67,14 @@ function Tabs({ if (activeValue === itemsPerPage.FIVE) position = middle; if (activeValue === itemsPerPage.TEN) position = end; - const animateContainerRef = useAnime({ + const { elementRef: animateContainerRef } = useAnime({ scale: [0, 1], opacity: [0, 1], easing: 'easeInOutElastic(1, .34)', duration: 1600, }); - const tabSliderRef = useAnime( + const { elementRef: tabSliderRef } = useAnime( { translateX: position, scaleX: [1.4, 1], From d62f532bbd78a4d82b5114285bc426f6a5a504a7 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 13 Nov 2023 23:51:57 +0200 Subject: [PATCH 04/64] refactor: tooltip animation improvements --- src/entities/movie/ui/Movie.tsx | 2 +- src/shared/hooks/useTooltip.ts | 51 ++++++++++++++++++++++----------- src/shared/ui/Tooltip.tsx | 2 +- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/entities/movie/ui/Movie.tsx b/src/entities/movie/ui/Movie.tsx index 404b666..51b6dc9 100644 --- a/src/entities/movie/ui/Movie.tsx +++ b/src/entities/movie/ui/Movie.tsx @@ -45,7 +45,7 @@ const Movie = memo(function Movie({ className="w-64 animate-springish cursor-pointer overflow-hidden rounded-5xl bg-neutral-950 text-gray-100 transition-all duration-200">
{ + onMouseEnter={(e) => { handleMouseMove(e); onMouseMove(e); }} diff --git a/src/shared/hooks/useTooltip.ts b/src/shared/hooks/useTooltip.ts index 417bc62..0b912c3 100644 --- a/src/shared/hooks/useTooltip.ts +++ b/src/shared/hooks/useTooltip.ts @@ -1,39 +1,56 @@ import { RefObject, useCallback, useEffect, useRef } from 'react'; +import anime from 'animejs/lib/anime.es.js'; import LocomotiveScroll from 'locomotive-scroll'; +import useAnime from './useAnime.ts'; import getElementMouseCoord from '../lib/helpers/getElementMouseCoord.ts'; -const HIDDEN = ['invisible', 'opacity-0', '!scale-[.3]']; const ELEMENT_POSITION_OFFSET = 120; function useTooltip(scroll: RefObject) { const tooltipRef = useRef(null); - const moveTooltip = useCallback((e: MouseEvent) => { - const { posX, posY } = getElementMouseCoord(document.body, e); + const { animation: showTooltipAnimation } = useAnime({ + targets: tooltipRef, + scale: [0, 1], + opacity: [0, 1], + duration: 1200, + }); - const pointerX = posX - ELEMENT_POSITION_OFFSET; - const pointerY = posY - ELEMENT_POSITION_OFFSET; + const { animation: hideTooltipAnimation } = useAnime({ + targets: tooltipRef, + scale: [1, 0], + opacity: [1, 0], + duration: 1200, + }); - if (tooltipRef.current) - tooltipRef.current.style.translate = `${pointerX}px ${pointerY}px`; - }, []); + const moveTooltip = useCallback( + (e: MouseEvent) => { + const { posX, posY } = getElementMouseCoord(document.body, e); + + const pointerX = posX - ELEMENT_POSITION_OFFSET; + const pointerY = posY - ELEMENT_POSITION_OFFSET; + + if (tooltipRef.current) + tooltipRef.current.style.translate = `${pointerX}px ${pointerY}px`; + }, + [tooltipRef], + ); const showTooltip = useCallback(() => { - tooltipRef.current?.classList.remove(...HIDDEN); - }, []); + showTooltipAnimation.current?.restart(); + }, [showTooltipAnimation]); const hideTooltip = useCallback(() => { - tooltipRef.current?.classList.add(...HIDDEN); - }, []); + hideTooltipAnimation.current?.restart(); + }, [hideTooltipAnimation]); useEffect(() => { - scroll.current?.on?.( - 'scroll', - () => tooltipRef.current?.classList.add(...HIDDEN), - ); - }, [scroll]); + scroll.current?.on?.('scroll', () => { + if (anime.running.length === 0) hideTooltipAnimation.current?.restart(); + }); + }, [scroll, hideTooltipAnimation]); useEffect(() => { document.addEventListener('mousemove', moveTooltip); diff --git a/src/shared/ui/Tooltip.tsx b/src/shared/ui/Tooltip.tsx index 13c59a8..a1f9774 100644 --- a/src/shared/ui/Tooltip.tsx +++ b/src/shared/ui/Tooltip.tsx @@ -11,7 +11,7 @@ function Tooltip({ innerRef, children }: ITooltipProps) { return createPortal(
+ className="pointer-events-none absolute left-0 top-0 flex h-28 w-28 items-center justify-center rounded-full bg-lime-400 p-6 text-center text-sm font-bold text-zinc-950 [transition:_translate_1500ms_cubic-bezier(.08,.9,.21,.98)]"> {children}
, document.body, From 4ca0182ed31ab5de292acf3fd17a3ff296d346f6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 09:55:14 +0200 Subject: [PATCH 05/64] fix: tooltip hiding on scroll --- src/shared/hooks/useTooltip.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/shared/hooks/useTooltip.ts b/src/shared/hooks/useTooltip.ts index 0b912c3..c23939d 100644 --- a/src/shared/hooks/useTooltip.ts +++ b/src/shared/hooks/useTooltip.ts @@ -1,6 +1,5 @@ import { RefObject, useCallback, useEffect, useRef } from 'react'; -import anime from 'animejs/lib/anime.es.js'; import LocomotiveScroll from 'locomotive-scroll'; import useAnime from './useAnime.ts'; @@ -11,7 +10,7 @@ const ELEMENT_POSITION_OFFSET = 120; function useTooltip(scroll: RefObject) { const tooltipRef = useRef(null); - const { animation: showTooltipAnimation } = useAnime({ + const { animation: showTooltipAnimation } = useAnime({ targets: tooltipRef, scale: [0, 1], opacity: [0, 1], @@ -48,7 +47,11 @@ function useTooltip(scroll: RefObject) { useEffect(() => { scroll.current?.on?.('scroll', () => { - if (anime.running.length === 0) hideTooltipAnimation.current?.restart(); + const isVisible = + tooltipRef.current && + getComputedStyle(tooltipRef.current).opacity === '1'; + + if (isVisible) hideTooltipAnimation.current?.restart(); }); }, [scroll, hideTooltipAnimation]); From 5635a10156ad8d2b589fb50caaeceb092b460c8c Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 10:26:14 +0200 Subject: [PATCH 06/64] feat: tabs component optimization & clean up --- src/shared/hooks/useAnime.ts | 22 +++++++++------------- src/shared/hooks/useTabs.ts | 32 ++++++++++++++++++++++++++++++++ src/shared/ui/Tabs.tsx | 34 +++++++++------------------------- 3 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 src/shared/hooks/useTabs.ts diff --git a/src/shared/hooks/useAnime.ts b/src/shared/hooks/useAnime.ts index 7dc4b84..481990f 100644 --- a/src/shared/hooks/useAnime.ts +++ b/src/shared/hooks/useAnime.ts @@ -33,19 +33,15 @@ function useAnime( const animeRef = useRef(); useEffect(() => { - if (params.targets && 'current' in params.targets) { - animeRef.current = anime({ - ...params, - targets: params.targets.current, - }); - } - - if (!params.targets || !('current' in params.targets)) { - animeRef.current = anime({ - ...params, - targets: elementRef.current, - }); - } + const targets = + elementRef.current || + (params.targets as unknown as RefObject)?.current || + params.targets; + + animeRef.current = anime({ + ...params, + targets, + }); }, [...deps]); return { elementRef, animation: animeRef }; diff --git a/src/shared/hooks/useTabs.ts b/src/shared/hooks/useTabs.ts new file mode 100644 index 0000000..a275ce0 --- /dev/null +++ b/src/shared/hooks/useTabs.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; + +import { itemsPerPage } from '../types/enums.ts'; + +function useTabs(activeValue: number, numOfTabs: number) { + const tabSliderWidth = useRef(0); + const containerRef = useRef(null); + const containerWidth = useRef(0); + const containerPadding = useRef(6); + + const halfContainer = containerWidth.current / 2; + const halfTabsSlider = tabSliderWidth.current / 2; + + const start = containerPadding.current; + const middle = halfContainer - halfTabsSlider; + const end = + containerWidth.current - tabSliderWidth.current - containerPadding.current; + + let position = start; + if (activeValue === itemsPerPage.FIVE) position = middle; + if (activeValue === itemsPerPage.TEN) position = end; + + useEffect(() => { + containerWidth.current = containerRef.current?.offsetWidth ?? 0; + tabSliderWidth.current = + containerWidth.current / numOfTabs - containerPadding.current; + }, [numOfTabs]); + + return { position, containerRef, tabSliderWidth }; +} + +export default useTabs; diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx index 5e2b2a2..a541a38 100644 --- a/src/shared/ui/Tabs.tsx +++ b/src/shared/ui/Tabs.tsx @@ -5,14 +5,12 @@ import { ReactNode, useCallback, useContext, - useEffect, useMemo, - useState, } from 'react'; import Button from './Button.tsx'; import useAnime from '../hooks/useAnime.ts'; -import { itemsPerPage } from '../types/enums.ts'; +import useTabs from '../hooks/useTabs.ts'; interface IOptionProps { children: string; @@ -37,7 +35,10 @@ function Tabs({ handler, activeValue, }: ISelectProps) { - const [containerRef, setContainerRef] = useState(null); + const { position, containerRef, tabSliderWidth } = useTabs( + Number(activeValue), + (children as ReactNode[])?.length || 1, + ); const handleSetValue = useCallback( (e: MouseEvent) => { @@ -53,21 +54,8 @@ function Tabs({ [handleSetValue, activeValue], ); - const containerPadding = 4; - const containerWidth = containerRef?.offsetWidth ?? 0; - const numOfTabs = (children as ReactNode[])?.length || 1; - const tabSliderWidth = containerWidth / numOfTabs - containerPadding; - - const start = containerPadding; - const middle = containerWidth / 2 - tabSliderWidth / 2; - const end = containerWidth - tabSliderWidth - containerPadding; - - let position = start; - - if (activeValue === itemsPerPage.FIVE) position = middle; - if (activeValue === itemsPerPage.TEN) position = end; - - const { elementRef: animateContainerRef } = useAnime({ + useAnime({ + targets: containerRef, scale: [0, 1], opacity: [0, 1], easing: 'easeInOutElastic(1, .34)', @@ -83,14 +71,10 @@ function Tabs({ [position], ); - useEffect(() => { - setContainerRef(animateContainerRef.current); - }, [animateContainerRef]); - return (
({ From 90fcfb31deaea6f0e829ddd235b674562aa94e30 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 14:22:00 +0200 Subject: [PATCH 07/64] fix: tab width --- src/shared/hooks/useTabs.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/shared/hooks/useTabs.ts b/src/shared/hooks/useTabs.ts index a275ce0..741d3f0 100644 --- a/src/shared/hooks/useTabs.ts +++ b/src/shared/hooks/useTabs.ts @@ -1,20 +1,20 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { itemsPerPage } from '../types/enums.ts'; function useTabs(activeValue: number, numOfTabs: number) { - const tabSliderWidth = useRef(0); + const [tabSliderWidth, setTabSliderWidth] = useState(0); const containerRef = useRef(null); const containerWidth = useRef(0); const containerPadding = useRef(6); const halfContainer = containerWidth.current / 2; - const halfTabsSlider = tabSliderWidth.current / 2; + const halfTabsSlider = tabSliderWidth / 2; const start = containerPadding.current; const middle = halfContainer - halfTabsSlider; const end = - containerWidth.current - tabSliderWidth.current - containerPadding.current; + containerWidth.current - tabSliderWidth - containerPadding.current; let position = start; if (activeValue === itemsPerPage.FIVE) position = middle; @@ -22,8 +22,9 @@ function useTabs(activeValue: number, numOfTabs: number) { useEffect(() => { containerWidth.current = containerRef.current?.offsetWidth ?? 0; - tabSliderWidth.current = - containerWidth.current / numOfTabs - containerPadding.current; + setTabSliderWidth( + containerWidth.current / numOfTabs - containerPadding.current, + ); }, [numOfTabs]); return { position, containerRef, tabSliderWidth }; From 362646813907eea594ceadb84217278a5073d642 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 14:24:10 +0200 Subject: [PATCH 08/64] refactor: performance optimization --- src/shared/hooks/useTooltip.ts | 2 ++ src/shared/ui/Tabs.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/hooks/useTooltip.ts b/src/shared/hooks/useTooltip.ts index c23939d..1415435 100644 --- a/src/shared/hooks/useTooltip.ts +++ b/src/shared/hooks/useTooltip.ts @@ -14,6 +14,7 @@ function useTooltip(scroll: RefObject) { targets: tooltipRef, scale: [0, 1], opacity: [0, 1], + translateZ: 0, duration: 1200, }); @@ -21,6 +22,7 @@ function useTooltip(scroll: RefObject) { targets: tooltipRef, scale: [1, 0], opacity: [1, 0], + translateZ: 0, duration: 1200, }); diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx index a541a38..2718831 100644 --- a/src/shared/ui/Tabs.tsx +++ b/src/shared/ui/Tabs.tsx @@ -59,12 +59,14 @@ function Tabs({ scale: [0, 1], opacity: [0, 1], easing: 'easeInOutElastic(1, .34)', + translateZ: 0, duration: 1600, }); const { elementRef: tabSliderRef } = useAnime( { translateX: position, + translateZ: 0, scaleX: [1.4, 1], easing: 'spring(.2, 80, 4, 0)', }, @@ -83,7 +85,7 @@ function Tabs({ From 02990267c34c839d62a681c52cbadefe75903428 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 18:05:55 +0200 Subject: [PATCH 09/64] feat: change logo animation --- src/widgets/Header/const/const.ts | 2 +- src/widgets/Header/ui/Logo.tsx | 63 +++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/widgets/Header/const/const.ts b/src/widgets/Header/const/const.ts index 99508f8..d491c44 100644 --- a/src/widgets/Header/const/const.ts +++ b/src/widgets/Header/const/const.ts @@ -1,3 +1,3 @@ export const APP_TITLE = 'cinemania'; -export const LOGO_LETTERS = ['🍿', ...APP_TITLE.split('')]; +export const LOGO_LETTERS = [...APP_TITLE.split('')]; export const LOGO_ANIMATION_OFFSET = 0.02; diff --git a/src/widgets/Header/ui/Logo.tsx b/src/widgets/Header/ui/Logo.tsx index a7b3dc0..ac8388b 100644 --- a/src/widgets/Header/ui/Logo.tsx +++ b/src/widgets/Header/ui/Logo.tsx @@ -1,23 +1,64 @@ // 👇 letter order will never change so we can use array index as a key /* eslint-disable react/no-array-index-key */ +import { useEffect, useRef } from 'react'; + +import anime from 'animejs/lib/anime.es.js'; + import LinkWithQuery from '../../../shared/ui/LinkWithQuery.tsx'; import { LOGO_ANIMATION_OFFSET, LOGO_LETTERS } from '../const/const.ts'; function Logo() { + const popCorn = useRef(null); + const elementRef = useRef(null); + + useEffect(() => { + anime + .timeline({ + targets: popCorn.current, + }) + .add({ + translateY: [-250, 0], + opacity: [0, 1], + scale: [2, 1], + translateZ: 0, + rotate: [anime.random(-30, 30), 0], + easing: 'easeOutElastic', + duration: 1100, + }) + .add( + { + targets: elementRef.current?.children, + translateY: [-120, 0], + opacity: [0, 1], + scale: [2, 1], + translateZ: 0, + delay: anime.stagger(80, { easing: 'easeInCirc' }), + duration: 900, + }, + '-=500', + ); + }, []); + return ( -

- {LOGO_LETTERS.map((letter, i) => ( - - {letter} - - ))} +

+ + 🍿 + +
+ {LOGO_LETTERS.map((letter, i) => ( + + {letter} + + ))} +

); From 4e2826698d110bc5e68c0188b6e24d5301a29791 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 18:35:14 +0200 Subject: [PATCH 10/64] feat: add github actions --- .github/actions/ci-setup/action.yml | 21 +++++++++++++++++++++ .github/workflows/deploy.yml | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .github/actions/ci-setup/action.yml create mode 100644 .github/workflows/deploy.yml diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml new file mode 100644 index 0000000..c72f6e4 --- /dev/null +++ b/.github/actions/ci-setup/action.yml @@ -0,0 +1,21 @@ +name: "Setup Continuous Integration" +description: "Cache Dependencies" +runs: + using: "composite" + steps: + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Cache NPM Dependencies + uses: actions/cache@v3 + id: cache-primes + with: + path: node_modules + key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} + + - name: Install Dependencies + run: npm install + shell: bash diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5f406f4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Deploy + +on: [push] + +permissions: + contents: write + +env: + NODE_VERSION: 18.16.0 + +jobs: + deploy: + name: Build And Deploy + runs-on: ubuntu-latest + + steps: + - name: Checkout️ + uses: actions/checkout@v3 + + - name: Setup Continuous integration + uses: ./.github/actions/ci-setup + + - name: Build + run: npm run build + + - name: Deploy + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: dist From dea035929ee8981763c668a36b5c042216c08c68 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 18:39:01 +0200 Subject: [PATCH 11/64] fix: config base path --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 27f8747..3aaf49d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,4 +12,5 @@ export default defineConfig({ setupFiles: './src/test/setup.ts', css: false, }, + base: './', }); From 7487c9925f6775676974029579c0509c94e353a1 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 14 Nov 2023 18:54:42 +0200 Subject: [PATCH 12/64] feat: add JSDoc comment to custom hooks --- src/shared/hooks/useDocumentTitle.ts | 6 ++++++ src/shared/hooks/useKey.ts | 8 ++++++++ src/shared/hooks/useRadialHover.ts | 9 +++++++++ src/shared/hooks/useScroll.ts | 8 ++++++++ src/shared/hooks/useScrollTop.ts | 10 ++++++++-- src/shared/hooks/useTabs.ts | 9 +++++++++ src/shared/hooks/useTooltip.ts | 10 ++++++++++ 7 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/shared/hooks/useDocumentTitle.ts b/src/shared/hooks/useDocumentTitle.ts index 60d887f..866ee06 100644 --- a/src/shared/hooks/useDocumentTitle.ts +++ b/src/shared/hooks/useDocumentTitle.ts @@ -2,6 +2,12 @@ import { useEffect } from 'react'; import { APP_TITLE } from '../const/const.ts'; +/** + * Updates the document title when a new title is provided. + * + * @param {string} newTitle - The new title for the document. + * @return {void} + */ function useDocumentTitle(newTitle: string) { useEffect(() => { if (newTitle) document.title = newTitle; diff --git a/src/shared/hooks/useKey.ts b/src/shared/hooks/useKey.ts index 40d7747..4615eff 100644 --- a/src/shared/hooks/useKey.ts +++ b/src/shared/hooks/useKey.ts @@ -1,5 +1,13 @@ import { useCallback, useEffect } from 'react'; +/** + * Registers a keyboard event listener that triggers an action when a specified key is pressed. + * + * @param {string} key - The key to listen for. + * @param {function} action - The action to perform when the specified key is pressed. + * + * @return {void} + */ function useKey(key: string, action: () => void) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { diff --git a/src/shared/hooks/useRadialHover.ts b/src/shared/hooks/useRadialHover.ts index ecab46d..f8b8557 100644 --- a/src/shared/hooks/useRadialHover.ts +++ b/src/shared/hooks/useRadialHover.ts @@ -2,6 +2,15 @@ import { MouseEvent, useRef } from 'react'; import createRadialHover from '../lib/helpers/animateRadialHover.ts'; +/** + * Attaches radial hover effect to a container element. + * + * @template TContainer - The type of the container element. + * @returns obj - Returns an object with event handler functions and a container reference.. + * @returns obj.handleMouseMove: (e: MouseEvent) => void. + * @returns obj.handleMouseOut: () => void. + * @returns obj.containerRef: React.MutableRefObject. + */ function useRadialHover() { const containerRef = useRef(null); const [radialHover, cleanUp] = createRadialHover(); diff --git a/src/shared/hooks/useScroll.ts b/src/shared/hooks/useScroll.ts index 2f70d5a..5baf5d8 100644 --- a/src/shared/hooks/useScroll.ts +++ b/src/shared/hooks/useScroll.ts @@ -2,6 +2,14 @@ import { RefObject, useEffect, useRef } from 'react'; import LocomotiveScroll from 'locomotive-scroll'; +/** + * Hook for using Locomotive Scroll. + * + * @template TContainer - The type of the container element. + * @returns obj - An object containing `containerRef` and `scrollRef` as React Ref Objects. + * @returns obj.containerRef {TContainer} - ref with the container element that scroll is attached to. + * @returns obj.scrollRef {LocomotiveScroll} - ref with the locomotive scroll instance. + */ function useScroll() { const containerRef = useRef(null); const scrollRef = useRef(); diff --git a/src/shared/hooks/useScrollTop.ts b/src/shared/hooks/useScrollTop.ts index b7476cb..5334097 100644 --- a/src/shared/hooks/useScrollTop.ts +++ b/src/shared/hooks/useScrollTop.ts @@ -5,10 +5,17 @@ import LocomotiveScroll from 'locomotive-scroll'; import { SCROLL_TOP_DURATION } from '../const/const.ts'; +/** + * Update scroll position to top when `currValue` changes. + * + * @param {unknown} currValue - The current value. + * @param {RefObject | undefined} scroll - The scroll reference. + * @param {...DependencyList} deps - Additional dependencies. + * @return {void} + */ function useScrollTop( currValue: unknown, scroll: RefObject | undefined, - callback?: () => void, ...deps: DependencyList ) { const prevValueRef = useRef(currValue); @@ -17,7 +24,6 @@ function useScrollTop( if (prevValueRef.current !== currValue) { prevValueRef.current = currValue; scroll?.current?.scrollTo?.('top', { duration: SCROLL_TOP_DURATION }); - callback?.(); } }, [currValue, scroll, ...deps]); } diff --git a/src/shared/hooks/useTabs.ts b/src/shared/hooks/useTabs.ts index 741d3f0..c3f2587 100644 --- a/src/shared/hooks/useTabs.ts +++ b/src/shared/hooks/useTabs.ts @@ -2,6 +2,15 @@ import { useEffect, useRef, useState } from 'react'; import { itemsPerPage } from '../types/enums.ts'; +/** + * Sets the position of a slider tab based on the active value and number of tabs. + * @param {number} activeValue - The active value representing the current tab. + * @param {number} numOfTabs - The number of tabs. + * @return {Object} obj An object containing the position, container reference, and tab slider width. + * @return {number} obj.position - The new position of the tab slider + * @return {RefObject} obj.containerRef - Ref with the container + * @return {RefObject} obj.tabSliderWidth - Ref with cached width of the tab slider + */ function useTabs(activeValue: number, numOfTabs: number) { const [tabSliderWidth, setTabSliderWidth] = useState(0); const containerRef = useRef(null); diff --git a/src/shared/hooks/useTooltip.ts b/src/shared/hooks/useTooltip.ts index 1415435..8d674e0 100644 --- a/src/shared/hooks/useTooltip.ts +++ b/src/shared/hooks/useTooltip.ts @@ -7,6 +7,16 @@ import getElementMouseCoord from '../lib/helpers/getElementMouseCoord.ts'; const ELEMENT_POSITION_OFFSET = 120; +/** + * Floating tooltip bubble following the mouse. + * The tooltip is displayed when the element is hovered and follows the mouse movement. + * + * @param {RefObject} scroll - The reference to the LocomotiveScroll instance. Used to hide the tooltip on scroll + * @return {Object} obj - An object containing the tooltip reference, hideTooltip function, and showTooltip function. + * @return {HTMLDivElement} obj.tooltipRef - The ref with the tooltip element. + * @return {() => void} obj.hideTooltip - Function that is used to hide the tooltip. For example on mouseLeave event. + * @return {() => void} obj.showTooltip - Function that is used to show the tooltip. For example on mouseEnter event. + */ function useTooltip(scroll: RefObject) { const tooltipRef = useRef(null); From fbf8ddb6dd22b83942115e74b15d3adac4bd8c6d Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 15:17:13 +0200 Subject: [PATCH 13/64] feat: add anime timeline hook --- src/shared/hooks/useAnime.ts | 26 +++----- src/shared/hooks/useAnimeTimeline.ts | 41 +++++++++++++ src/shared/lib/helpers/reduceAnimeTargets.ts | 41 +++++++++++++ src/shared/types/interfaces.ts | 8 +++ src/shared/types/types.ts | 10 +++ src/widgets/Header/ui/Logo.tsx | 64 +++++++++----------- 6 files changed, 137 insertions(+), 53 deletions(-) create mode 100644 src/shared/hooks/useAnimeTimeline.ts create mode 100644 src/shared/lib/helpers/reduceAnimeTargets.ts diff --git a/src/shared/hooks/useAnime.ts b/src/shared/hooks/useAnime.ts index 481990f..9ee074f 100644 --- a/src/shared/hooks/useAnime.ts +++ b/src/shared/hooks/useAnime.ts @@ -1,20 +1,13 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { DependencyList, RefObject, useEffect, useRef } from 'react'; +import { DependencyList, useEffect, useRef } from 'react'; -import { AnimeInstance, AnimeParams } from 'animejs'; +import { AnimeInstance } from 'animejs'; import anime from 'animejs/lib/anime.es.js'; -type AnimeTarget = - | HTMLElement - | SVGElement - | NodeList - | RefObject - | null; - -interface IParams extends Omit { - targets?: AnimeTarget; -} +import reduceAnimeTargets from '../lib/helpers/reduceAnimeTargets.ts'; +import { IParams } from '../types/interfaces.ts'; +import { AnimeTarget } from '../types/types.ts'; /** * Applies anime.js animation to the element using the specified parameters and dependencies. @@ -22,21 +15,20 @@ interface IParams extends Omit { * @template TElem - The type of the HTML element to animate. * @param {AnimeParams} params - The parameters used to configure the animation. * @param {DependencyList} [deps=[]] - The list of dependencies that trigger the animation when changed. + * @param isChildren * @return {React.MutableRefObject} The reference to the HTML element being animated. * You can also pass the targets param to params object, if your component already have ref to an element that needs to be animated */ -function useAnime( +function useAnime( params: IParams, deps: DependencyList = [], + isChildren: boolean = false, ) { const elementRef = useRef(null); const animeRef = useRef(); useEffect(() => { - const targets = - elementRef.current || - (params.targets as unknown as RefObject)?.current || - params.targets; + const targets = reduceAnimeTargets(params, elementRef, isChildren); animeRef.current = anime({ ...params, diff --git a/src/shared/hooks/useAnimeTimeline.ts b/src/shared/hooks/useAnimeTimeline.ts new file mode 100644 index 0000000..c06e488 --- /dev/null +++ b/src/shared/hooks/useAnimeTimeline.ts @@ -0,0 +1,41 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { DependencyList, useEffect, useRef } from 'react'; + +import { AnimeTimelineInstance } from 'animejs'; +import anime from 'animejs/lib/anime.es'; + +import reduceAnimeTargets from '../lib/helpers/reduceAnimeTargets.ts'; +import { IParams } from '../types/interfaces.ts'; + +/** + * A hook to use Anime.js timeline functionality. + * + * @param {DependencyList} deps - An array of dependencies to watch for changes. + * @param {IParams[]} add - Additional params to add to the timeline. + * @returns A reference to the Anime.js timeline instance. + */ +function useAnimeTimeline(deps: DependencyList = [], ...add: IParams[]) { + const timelineRef = useRef(); + + useEffect(() => { + timelineRef.current = anime.timeline({}); + + add.forEach((param) => { + const { timelineOffset, isChildren, ...restParams } = param; + const targets = reduceAnimeTargets(param, null, isChildren); + + timelineRef.current = timelineRef.current?.add( + { + ...restParams, + targets, + }, + timelineOffset, + ); + }); + }, [...deps]); + + return timelineRef; +} + +export default useAnimeTimeline; diff --git a/src/shared/lib/helpers/reduceAnimeTargets.ts b/src/shared/lib/helpers/reduceAnimeTargets.ts new file mode 100644 index 0000000..0e79e03 --- /dev/null +++ b/src/shared/lib/helpers/reduceAnimeTargets.ts @@ -0,0 +1,41 @@ +import { RefObject } from 'react'; + +import { IParams } from '../../types/interfaces.ts'; +import { AnimeTarget } from '../../types/types.ts'; + +/** + * Reduces the list of Anime.js targets based on the provided parameters. + * + * @param {IParams} params - The parameters for reducing the anime targets. + * @param {RefObject | null} elementRef - The reference to the anime target element. + * @param {boolean} isChildren - Indicates whether to consider the children of the anime target element. + * + * @returns {T | T[] | null} - The reduced anime target(s). + */ +function reduceAnimeTargets( + params: IParams, + elementRef: RefObject | null, + isChildren: boolean, +) { + let { targets } = params; + + if (elementRef?.current) { + targets = elementRef.current; + + if (isChildren && 'children' in elementRef.current) { + targets = elementRef.current?.children; + } + } + + if (params?.targets && 'current' in params.targets) { + targets = params.targets.current; + + if (isChildren) { + targets = params.targets.current?.children; + } + } + + return targets; +} + +export default reduceAnimeTargets; diff --git a/src/shared/types/interfaces.ts b/src/shared/types/interfaces.ts index 65bcf34..5bff7e2 100644 --- a/src/shared/types/interfaces.ts +++ b/src/shared/types/interfaces.ts @@ -1,5 +1,13 @@ import { ReactNode } from 'react'; +import { AnimeParams } from 'animejs'; + +import { AnimeTarget } from './types.ts'; + export interface IChildren { children: ReactNode; } + +export interface IParams extends Omit { + targets?: AnimeTarget; +} diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index eb19619..0235257 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -1,3 +1,5 @@ +import { RefObject } from 'react'; + import { itemsPerPage, urlParams } from './enums.ts'; export type Movie = Readonly<{ @@ -56,3 +58,11 @@ export type ApiMovieResponse = Readonly<{ export type ItemsPerPage = (typeof itemsPerPage)[keyof typeof itemsPerPage]; export type UrlParams = (typeof urlParams)[keyof typeof urlParams]; + +export type AnimeTarget = + | HTMLElement + | SVGElement + | NodeList + | RefObject + | HTMLCollection + | null; diff --git a/src/widgets/Header/ui/Logo.tsx b/src/widgets/Header/ui/Logo.tsx index ac8388b..98ec994 100644 --- a/src/widgets/Header/ui/Logo.tsx +++ b/src/widgets/Header/ui/Logo.tsx @@ -1,44 +1,42 @@ // 👇 letter order will never change so we can use array index as a key /* eslint-disable react/no-array-index-key */ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import anime from 'animejs/lib/anime.es.js'; +import useAnimeTimeline from '../../../shared/hooks/useAnimeTimeline.ts'; import LinkWithQuery from '../../../shared/ui/LinkWithQuery.tsx'; -import { LOGO_ANIMATION_OFFSET, LOGO_LETTERS } from '../const/const.ts'; +import { LOGO_LETTERS } from '../const/const.ts'; function Logo() { const popCorn = useRef(null); const elementRef = useRef(null); - useEffect(() => { - anime - .timeline({ - targets: popCorn.current, - }) - .add({ - translateY: [-250, 0], - opacity: [0, 1], - scale: [2, 1], - translateZ: 0, - rotate: [anime.random(-30, 30), 0], - easing: 'easeOutElastic', - duration: 1100, - }) - .add( - { - targets: elementRef.current?.children, - translateY: [-120, 0], - opacity: [0, 1], - scale: [2, 1], - translateZ: 0, - delay: anime.stagger(80, { easing: 'easeInCirc' }), - duration: 900, - }, - '-=500', - ); - }, []); + useAnimeTimeline( + [], + { + targets: popCorn, + translateY: [-250, 0], + opacity: [0, 1], + scale: [2, 1], + translateZ: 0, + rotate: [anime.random(-30, 30), 0], + easing: 'easeOutElastic', + duration: 1100, + }, + { + targets: elementRef, + translateY: [-120, 0], + opacity: [0, 1], + scale: [2, 1], + translateZ: 0, + delay: anime.stagger(80, { easing: 'easeInCirc' }), + duration: 900, + timelineOffset: '-=500', + isChildren: true, + }, + ); return ( @@ -48,13 +46,7 @@ function Logo() {
{LOGO_LETTERS.map((letter, i) => ( - + {letter} ))} From 55dd7d3aa2ad81a021250f65474d5b8a303c61d6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 15:55:02 +0200 Subject: [PATCH 14/64] refactor: adjust JSDoc comment --- src/shared/lib/helpers/reduceAnimeTargets.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/lib/helpers/reduceAnimeTargets.ts b/src/shared/lib/helpers/reduceAnimeTargets.ts index 0e79e03..356779d 100644 --- a/src/shared/lib/helpers/reduceAnimeTargets.ts +++ b/src/shared/lib/helpers/reduceAnimeTargets.ts @@ -6,6 +6,7 @@ import { AnimeTarget } from '../../types/types.ts'; /** * Reduces the list of Anime.js targets based on the provided parameters. * + * @template {AnimeTarget} T - Generic elementRef * @param {IParams} params - The parameters for reducing the anime targets. * @param {RefObject | null} elementRef - The reference to the anime target element. * @param {boolean} isChildren - Indicates whether to consider the children of the anime target element. From ca22355f963d597644f921b596651f169f7d971e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 16:28:40 +0200 Subject: [PATCH 15/64] chore: add redux toolkit --- package-lock.json | 86 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 88 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7eff7a7..4682c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cinemania", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^1.9.7", "animejs": "^3.2.1", "clsx": "^2.0.0", "locomotive-scroll": "^4.1.4", @@ -27,6 +28,7 @@ "@types/locomotive-scroll": "^4.1.2", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/react-redux": "^7.1.30", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", @@ -1147,6 +1149,29 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@reduxjs/toolkit": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz", + "integrity": "sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==", + "dependencies": { + "immer": "^9.0.21", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", + "reselect": "^4.1.8" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.11.0.tgz", @@ -1521,6 +1546,16 @@ "integrity": "sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==", "dev": true }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dev": true, + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1586,6 +1621,18 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.30", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.30.tgz", + "integrity": "sha512-i2kqM6YaUwFKduamV6QM/uHbb0eCP8f8ZQ/0yWf+BsAVVsZPRYJ9eeGWZ3uxLfWwwA0SrPRMTPTqsPFkY3HZdA==", + "dev": true, + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/scheduler": { "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", @@ -4690,6 +4737,15 @@ "integrity": "sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==", "dev": true }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -4808,6 +4864,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7522,6 +7587,22 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "peerDependencies": { + "redux": "^4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -7581,6 +7662,11 @@ "optional": true, "peer": true }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/package.json b/package.json index b75eff4..e69fbcc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:committed": "vitest --run" }, "dependencies": { + "@reduxjs/toolkit": "^1.9.7", "animejs": "^3.2.1", "clsx": "^2.0.0", "locomotive-scroll": "^4.1.4", @@ -36,6 +37,7 @@ "@types/locomotive-scroll": "^4.1.2", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", + "@types/react-redux": "^7.1.30", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", From 93ab7c5c5b24dd8cc10b08b2457c32e65583fddf Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 16:29:22 +0200 Subject: [PATCH 16/64] feat: setup redux store --- src/app/store/store.ts | 13 +++++++++++++ src/main.tsx | 9 +++++++-- src/shared/hooks/useAppDispatch.ts | 6 ++++++ src/shared/hooks/useAppSelector.ts | 6 ++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/app/store/store.ts create mode 100644 src/shared/hooks/useAppDispatch.ts create mode 100644 src/shared/hooks/useAppSelector.ts diff --git a/src/app/store/store.ts b/src/app/store/store.ts new file mode 100644 index 0000000..94b04c1 --- /dev/null +++ b/src/app/store/store.ts @@ -0,0 +1,13 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; + +export type RootState = ReturnType; +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; + +const rootReducer = combineReducers({}); + +export const setupStore = (preloadedState?: Partial) => + configureStore({ + preloadedState, + reducer: rootReducer, + }); diff --git a/src/main.tsx b/src/main.tsx index 531f86a..a7a0763 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import './index.css'; import App from './app/App.tsx'; +import { setupStore } from './app/store/store.ts'; -import './index.css'; +const store = setupStore(); ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/src/shared/hooks/useAppDispatch.ts b/src/shared/hooks/useAppDispatch.ts new file mode 100644 index 0000000..7cd4511 --- /dev/null +++ b/src/shared/hooks/useAppDispatch.ts @@ -0,0 +1,6 @@ +import { useDispatch } from 'react-redux'; + +import { AppDispatch } from '../../app/store/store.ts'; + +const useAppDispatch = () => useDispatch(); +export default useAppDispatch; diff --git a/src/shared/hooks/useAppSelector.ts b/src/shared/hooks/useAppSelector.ts new file mode 100644 index 0000000..a96e5db --- /dev/null +++ b/src/shared/hooks/useAppSelector.ts @@ -0,0 +1,6 @@ +import { TypedUseSelectorHook, useSelector } from 'react-redux'; + +import { RootState } from '../../app/store/store.ts'; + +const useAppSelector: TypedUseSelectorHook = useSelector; +export default useAppSelector; From 8dc1c38b7eeaa8bb70139d161cca72c67f411fbe Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 18:08:02 +0200 Subject: [PATCH 17/64] chore: change environment --- .gitignore | 1 + src/vite-env.d.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 3e12931..0651537 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.njsproj *.sln *.sw? +*.env diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..f230ace 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_API_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From 2552269a403d1ab8afd4218c65a08b87cfb9a37e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 18:14:31 +0200 Subject: [PATCH 18/64] feat: setup api with RTK Query --- src/app/store/store.ts | 19 +++++++++++++------ src/entities/movie/api/movieApi.ts | 29 +++++++++++++++++++++++++++++ src/shared/api/baseQuery.ts | 7 +++++++ src/shared/api/rootApi.ts | 11 +++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/entities/movie/api/movieApi.ts create mode 100644 src/shared/api/baseQuery.ts create mode 100644 src/shared/api/rootApi.ts diff --git a/src/app/store/store.ts b/src/app/store/store.ts index 94b04c1..7e1ee72 100644 --- a/src/app/store/store.ts +++ b/src/app/store/store.ts @@ -1,13 +1,20 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; -export type RootState = ReturnType; -export type AppStore = ReturnType; -export type AppDispatch = AppStore['dispatch']; +import { movieApi } from '../../entities/movie/api/movieApi.ts'; +import { searchReducer } from '../../features/Search/model/slice.ts'; -const rootReducer = combineReducers({}); +const rootReducer = combineReducers({ + [movieApi.reducerPath]: movieApi.reducer, + searchReducer, +}); -export const setupStore = (preloadedState?: Partial) => +export const setupStore = () => configureStore({ - preloadedState, reducer: rootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(movieApi.middleware), }); + +export type AppStore = ReturnType; +export type RootState = ReturnType; +export type AppDispatch = AppStore['dispatch']; diff --git a/src/entities/movie/api/movieApi.ts b/src/entities/movie/api/movieApi.ts new file mode 100644 index 0000000..504756b --- /dev/null +++ b/src/entities/movie/api/movieApi.ts @@ -0,0 +1,29 @@ +import { IInitialState } from '../../../features/Search/types/types.ts'; +import rootApi from '../../../shared/api/rootApi.ts'; +import { QUERY_FALLBACK } from '../../../shared/const/const.ts'; +import { + ApiMovieListResponse, + ApiMovieResponse, +} from '../../../shared/types/types.ts'; + +interface IMovieListParams { + query: IInitialState['query']; + page: string; +} + +export const movieApi = rootApi.injectEndpoints({ + endpoints: (build) => ({ + getMovieList: build.query({ + query: ({ query, page }) => + `?apikey=${import.meta.env.VITE_API_KEY}&s=${ + query || QUERY_FALLBACK + }&page=${page}`, + }), + + getMovie: build.query({ + query: (id) => `?apikey=${import.meta.env.VITE_API_KEY}&i=${id}`, + }), + }), +}); + +export const { useGetMovieListQuery, useGetMovieQuery } = movieApi; diff --git a/src/shared/api/baseQuery.ts b/src/shared/api/baseQuery.ts new file mode 100644 index 0000000..c28942c --- /dev/null +++ b/src/shared/api/baseQuery.ts @@ -0,0 +1,7 @@ +import { fetchBaseQuery } from '@reduxjs/toolkit/dist/query/react'; + +const baseQuery = fetchBaseQuery({ + baseUrl: `${import.meta.env.VITE_API_URL}`, +}); + +export default baseQuery; diff --git a/src/shared/api/rootApi.ts b/src/shared/api/rootApi.ts new file mode 100644 index 0000000..ad89ec3 --- /dev/null +++ b/src/shared/api/rootApi.ts @@ -0,0 +1,11 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import baseQuery from './baseQuery.ts'; + +const rootApi = createApi({ + reducerPath: 'rootApi', + baseQuery, + endpoints: () => ({}), +}); + +export default rootApi; From a78348cbe67b46836ff5087757958d6e61d655a6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 18:16:54 +0200 Subject: [PATCH 19/64] feat: replace fetch requests with RTK Query --- src/entities/movie/Movie.test.tsx | 2 +- .../movie/api/{apiMovie.ts => apiMovie-v1.ts} | 16 ++-- src/entities/movie/loader.ts | 2 +- src/features/MovieList/MovieList.tsx | 22 ++++-- .../Search/context/SearchProvider.tsx | 2 +- src/pages/AppLayout/AppLayout.tsx | 73 +++++++++---------- src/shared/const/const.ts | 3 - src/test/mocks/handlers.ts | 3 +- 8 files changed, 65 insertions(+), 58 deletions(-) rename src/entities/movie/api/{apiMovie.ts => apiMovie-v1.ts} (70%) diff --git a/src/entities/movie/Movie.test.tsx b/src/entities/movie/Movie.test.tsx index 87a2dd8..b4593ed 100644 --- a/src/entities/movie/Movie.test.tsx +++ b/src/entities/movie/Movie.test.tsx @@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it } from 'vitest'; -import * as apiMovie from './api/apiMovie.ts'; +import * as apiMovie from './api/apiMovie-v1.ts'; import Movie from './ui/Movie.tsx'; import * as useSearch from '../../features/Search/hooks/useSearch.ts'; import createMockSearchContext from '../../test/helpers/createMockSearchContext.ts'; diff --git a/src/entities/movie/api/apiMovie.ts b/src/entities/movie/api/apiMovie-v1.ts similarity index 70% rename from src/entities/movie/api/apiMovie.ts rename to src/entities/movie/api/apiMovie-v1.ts index e4208e7..2e2dc0e 100644 --- a/src/entities/movie/api/apiMovie.ts +++ b/src/entities/movie/api/apiMovie-v1.ts @@ -1,8 +1,4 @@ -import { - API_URL, - DEFAULT_PAGE, - QUERY_FALLBACK, -} from '../../../shared/const/const.ts'; +import { DEFAULT_PAGE, QUERY_FALLBACK } from '../../../shared/const/const.ts'; import { ApiErrorResponse, ApiMovieListResponse, @@ -14,7 +10,9 @@ export async function getMovieList( page: number = DEFAULT_PAGE, ): Promise { const response = await fetch( - `${API_URL}&s=${query || QUERY_FALLBACK}&page=${page}`, + `${import.meta.env.VITE_API_URL}?apikey=${import.meta.env.VITE_API_KEY}&s=${ + query || QUERY_FALLBACK + }&page=${page}`, ); if (!response.ok) throw new Error('Something went wrong fetching movies!'); @@ -29,7 +27,11 @@ export async function getMovieList( } export async function getMovie(id: string) { - const response = await fetch(`${API_URL}&i=${id}`); + const response = await fetch( + `${import.meta.env.VITE_API_URL}?apikey=${ + import.meta.env.VITE_API_KEY + }&i=${id}`, + ); if (!response.ok) throw new Error('Something went wrong fetching movies!'); diff --git a/src/entities/movie/loader.ts b/src/entities/movie/loader.ts index 1b94641..59075ee 100644 --- a/src/entities/movie/loader.ts +++ b/src/entities/movie/loader.ts @@ -1,6 +1,6 @@ import { Params } from 'react-router-dom'; -import { getMovie } from './api/apiMovie.ts'; +import { getMovie } from './api/apiMovie-v1.ts'; interface ILoaderParams { params: Params; diff --git a/src/features/MovieList/MovieList.tsx b/src/features/MovieList/MovieList.tsx index f61f39f..77fad34 100644 --- a/src/features/MovieList/MovieList.tsx +++ b/src/features/MovieList/MovieList.tsx @@ -3,9 +3,13 @@ import { PropsWithChildren, ReactNode, RefObject } from 'react'; import LocomotiveScroll from 'locomotive-scroll'; import useListClick from './hooks/useListClick.ts'; -import useMovieList from './hooks/useMovieList.ts'; -import NotFound from './ui/NotFound.tsx'; +import { useGetMovieListQuery } from '../../entities/movie/api/movieApi.ts'; +import useAppSelector from '../../shared/hooks/useAppSelector.ts'; +import useUrl from '../../shared/hooks/useUrl.ts'; +import { urlParams } from '../../shared/types/enums.ts'; import { Movie } from '../../shared/types/types.ts'; +import Loader from '../../widgets/Loader/Loader.tsx'; +import selectSearchQuery from '../Search/lib/selectors/selectSearchQuery.ts'; interface IMovieListProps extends PropsWithChildren { scroll: RefObject; @@ -13,10 +17,18 @@ interface IMovieListProps extends PropsWithChildren { } function MovieList({ scroll, render, children }: IMovieListProps) { - const { renderMovies, noMovies } = useMovieList(); const { listRef, handleClick } = useListClick(scroll); + const { readUrl } = useUrl(); - if (noMovies) return ; + const query = useAppSelector(selectSearchQuery); + const page = readUrl(urlParams.PAGE); + + const { data: movieList, isLoading } = useGetMovieListQuery({ + page, + query, + }); + + if (isLoading) return ; return (
    {children} - {renderMovies?.map(render)} + {movieList?.Search.map(render)}
); } diff --git a/src/features/Search/context/SearchProvider.tsx b/src/features/Search/context/SearchProvider.tsx index 055531a..a6e7014 100644 --- a/src/features/Search/context/SearchProvider.tsx +++ b/src/features/Search/context/SearchProvider.tsx @@ -6,7 +6,7 @@ import { useReducer, } from 'react'; -import { getMovieList } from '../../../entities/movie/api/apiMovie.ts'; +import { getMovieList } from '../../../entities/movie/api/apiMovie-v1.ts'; import { DEFAULT_PAGE, LOCAL_STORAGE_SEARCH_QUERY, diff --git a/src/pages/AppLayout/AppLayout.tsx b/src/pages/AppLayout/AppLayout.tsx index c4a0e4b..10fc563 100644 --- a/src/pages/AppLayout/AppLayout.tsx +++ b/src/pages/AppLayout/AppLayout.tsx @@ -6,7 +6,6 @@ import MovieList from '../../features/MovieList/MovieList.tsx'; import MovieListHeader from '../../features/MovieList/ui/MovieListHeader.tsx'; import PageNum from '../../features/MovieList/ui/PageNum.tsx'; import Pagination from '../../features/Pagination/Pagination.tsx'; -import SearchProvider from '../../features/Search/context/SearchProvider.tsx'; import Search from '../../features/Search/Search.tsx'; import useScroll from '../../shared/hooks/useScroll.ts'; import useTooltip from '../../shared/hooks/useTooltip.ts'; @@ -22,43 +21,41 @@ function AppLayout() { const { tooltipRef, hideTooltip, showTooltip } = useTooltip(scrollRef); return ( - -
-
- Click for details - - -
- - - -
-
- ( - - )}> - - - - - -
- -
-
-
+
+
+ Click for details + + +
+ + + +
+
+ ( + + )}> + + + + + +
+ +
+
); } diff --git a/src/shared/const/const.ts b/src/shared/const/const.ts index d3da512..ba1bca0 100644 --- a/src/shared/const/const.ts +++ b/src/shared/const/const.ts @@ -1,8 +1,5 @@ import { urlParams } from '../types/enums.ts'; -export const API_KEY = 'dbb72d83'; -export const API_URL = `https://www.omdbapi.com/?apikey=${API_KEY}`; -export const API_URL_NO_KEY = 'https://www.omdbapi.com/'; export const QUERY_FALLBACK = 'all'; export const NOT_EXIST = 'N/A'; export const LOCAL_STORAGE_SEARCH_QUERY = 'search-query'; diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index e5ba721..0570923 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -1,11 +1,10 @@ import { delay, http, HttpResponse } from 'msw'; import { mockMovieDetails, mockMovieDetailsNoPoster } from './data.ts'; -import { API_URL_NO_KEY } from '../../shared/const/const.ts'; import { NO_POSTER_QUERY_TEST_CASE } from '../const/const.ts'; const handlers = [ - http.get(`${API_URL_NO_KEY}`, async ({ request }) => { + http.get(`${import.meta.env.VITE_API_URL}`, async ({ request }) => { await delay(); const url = new URL(request.url); From 2a92693ccf36c5d0b7cd4917e4f4f5c44acee273 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 18:17:37 +0200 Subject: [PATCH 20/64] chore: add redux --- package-lock.json | 75 +++++++++++++++++++++++++++++++++++++++-------- package.json | 2 ++ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4682c14..247ad90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "locomotive-scroll": "^4.1.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-redux": "^8.1.3", "react-router-dom": "^6.18.0", + "redux": "^4.2.1", "tailwind-merge": "^2.0.0" }, "devDependencies": { @@ -1550,7 +1552,6 @@ "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", - "dev": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -1598,14 +1599,12 @@ "node_modules/@types/prop-types": { "version": "15.7.9", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", - "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==", - "dev": true + "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==" }, "node_modules/@types/react": { "version": "18.2.33", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.33.tgz", "integrity": "sha512-v+I7S+hu3PIBoVkKGpSYYpiBT1ijqEzWpzQD62/jm4K74hPpSP7FF9BnKG6+fg2+62weJYkkBWDJlZt5JO/9hg==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1616,7 +1615,7 @@ "version": "18.2.14", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.14.tgz", "integrity": "sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -1636,8 +1635,7 @@ "node_modules/@types/scheduler": { "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", - "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==", - "dev": true + "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==" }, "node_modules/@types/semver": { "version": "7.5.4", @@ -1651,6 +1649,11 @@ "integrity": "sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.0.tgz", @@ -2965,8 +2968,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -4741,7 +4743,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dev": true, "dependencies": { "react-is": "^16.7.0" } @@ -7497,8 +7498,50 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-redux": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", + "integrity": "sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, "node_modules/react-refresh": { "version": "0.14.0", @@ -9022,6 +9065,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index e69fbcc..decf8a2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "locomotive-scroll": "^4.1.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-redux": "^8.1.3", "react-router-dom": "^6.18.0", + "redux": "^4.2.1", "tailwind-merge": "^2.0.0" }, "devDependencies": { From f4653258210e218779aa30aca55acc802b998621 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 15 Nov 2023 18:19:08 +0200 Subject: [PATCH 21/64] feat: add searchSlice --- .../Search/lib/selectors/selectSearchQuery.ts | 10 ++++++++++ src/features/Search/model/slice.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/features/Search/lib/selectors/selectSearchQuery.ts create mode 100644 src/features/Search/model/slice.ts diff --git a/src/features/Search/lib/selectors/selectSearchQuery.ts b/src/features/Search/lib/selectors/selectSearchQuery.ts new file mode 100644 index 0000000..ff9a7ed --- /dev/null +++ b/src/features/Search/lib/selectors/selectSearchQuery.ts @@ -0,0 +1,10 @@ +import { createSelector } from '@reduxjs/toolkit'; + +import { RootState } from '../../../../app/store/store.ts'; + +const selectSearchQuery = createSelector( + (state: RootState) => state.searchReducer.query, + (query) => query, +); + +export default selectSearchQuery; diff --git a/src/features/Search/model/slice.ts b/src/features/Search/model/slice.ts new file mode 100644 index 0000000..73cfff8 --- /dev/null +++ b/src/features/Search/model/slice.ts @@ -0,0 +1,17 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +const initialState = { + query: '', +}; + +export const searchSlice = createSlice({ + name: 'search', + initialState, + reducers: { + queryUpdated: (state, action: PayloadAction) => { + state.query = action.payload; + }, + }, +}); + +export const searchReducer = searchSlice.reducer; From a1f03894b0d4b2c1b082dd9b2db2c3b288fbea02 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 16 Nov 2023 16:43:00 +0200 Subject: [PATCH 22/64] feat: change logo animation --- src/shared/hooks/useAnimeTimeline.ts | 17 +++- src/shared/lib/helpers/reduceAnimeTargets.ts | 6 +- src/widgets/Header/ui/Logo.tsx | 85 +++++++++++++++++--- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/shared/hooks/useAnimeTimeline.ts b/src/shared/hooks/useAnimeTimeline.ts index c06e488..fa54832 100644 --- a/src/shared/hooks/useAnimeTimeline.ts +++ b/src/shared/hooks/useAnimeTimeline.ts @@ -7,18 +7,33 @@ import anime from 'animejs/lib/anime.es'; import reduceAnimeTargets from '../lib/helpers/reduceAnimeTargets.ts'; import { IParams } from '../types/interfaces.ts'; +import { AnimeTarget } from '../types/types.ts'; + +interface IOptions { + set?: { targets: AnimeTarget; values: { [key: string]: number } }[]; +} /** * A hook to use Anime.js timeline functionality. * + * @param {IOptions} options - anime options. * @param {DependencyList} deps - An array of dependencies to watch for changes. * @param {IParams[]} add - Additional params to add to the timeline. * @returns A reference to the Anime.js timeline instance. */ -function useAnimeTimeline(deps: DependencyList = [], ...add: IParams[]) { +function useAnimeTimeline( + options: IOptions = {}, + deps: DependencyList = [], + ...add: IParams[] +) { const timelineRef = useRef(); useEffect(() => { + options.set?.forEach((opt) => { + const targets = reduceAnimeTargets(opt); + anime.set(targets, opt.values); + }); + timelineRef.current = anime.timeline({}); add.forEach((param) => { diff --git a/src/shared/lib/helpers/reduceAnimeTargets.ts b/src/shared/lib/helpers/reduceAnimeTargets.ts index 356779d..a575e97 100644 --- a/src/shared/lib/helpers/reduceAnimeTargets.ts +++ b/src/shared/lib/helpers/reduceAnimeTargets.ts @@ -15,8 +15,8 @@ import { AnimeTarget } from '../../types/types.ts'; */ function reduceAnimeTargets( params: IParams, - elementRef: RefObject | null, - isChildren: boolean, + elementRef: RefObject | null = null, + isChildren: boolean = false, ) { let { targets } = params; @@ -36,7 +36,7 @@ function reduceAnimeTargets( } } - return targets; + return targets as AnimeTarget; } export default reduceAnimeTargets; diff --git a/src/widgets/Header/ui/Logo.tsx b/src/widgets/Header/ui/Logo.tsx index 98ec994..c19a67e 100644 --- a/src/widgets/Header/ui/Logo.tsx +++ b/src/widgets/Header/ui/Logo.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react'; -import anime from 'animejs/lib/anime.es.js'; +import anime from 'animejs/lib/anime.es'; import useAnimeTimeline from '../../../shared/hooks/useAnimeTimeline.ts'; import LinkWithQuery from '../../../shared/ui/LinkWithQuery.tsx'; @@ -14,27 +14,88 @@ function Logo() { const elementRef = useRef(null); useAnimeTimeline( + {}, [], + { + targets: elementRef, + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { + value: [200, -30], + duration: 300, + easing: 'cubicBezier(0.225, 1, 0.915, 0.980)', + endDelay: 20, + }, + { value: 0, duration: 120, easing: 'easeInQuad' }, + ], + scaleX: [ + { value: [0, 0.8], duration: 190, easing: 'easeInQuad' }, + { value: 0.8, duration: 300, easing: 'easeInQuad' }, + { value: 1.1, duration: 90, easing: 'easeOutQuad' }, + { value: 0.95, duration: 120, easing: 'easeOutCirc' }, + { value: 1, duration: 100, easing: 'easeOutCirc' }, + ], + scaleY: [ + { value: [0.2, 1.3], duration: 200, easing: 'easeInSine' }, + { value: 0.7, duration: 440, easing: 'easeOutQuad' }, + { value: 1.2, duration: 100, easing: 'easeOutCirc' }, + { value: 1, duration: 100, easing: 'easeOutCirc' }, + ], + translateZ: 0, + delay: anime.stagger(120, { easing: 'easeInCirc' }), + isChildren: true, + }, { targets: popCorn, - translateY: [-250, 0], opacity: [0, 1], - scale: [2, 1], + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { value: [-250, 0], duration: 270, easing: 'easeOutCubic' }, + { value: -10, duration: 120, easing: 'easeInQuad' }, + { value: 0, duration: 200, easing: 'easeInQuad' }, + ], + scaleX: [ + { value: [0, 0.8], duration: 120, easing: 'easeOutQuad' }, + { value: 2, duration: 190, easing: 'easeInQuad' }, + { value: 1, duration: 120, easing: 'easeOutQuad' }, + { value: 0.9, duration: 120, easing: 'easeOutQuad' }, + { value: 1, duration: 700, easing: 'easeOutElastic' }, + ], + scaleY: [ + { value: 2, duration: 120, easing: 'easeOutQuad' }, + { value: 0.2, duration: 190, easing: 'easeOutQuad' }, + { value: 1, duration: 120, easing: 'easeOutCirc' }, + { value: 1.2, duration: 120, easing: 'easeOutCirc' }, + { value: 1, duration: 700, easing: 'easeOutElastic' }, + ], translateZ: 0, - rotate: [anime.random(-30, 30), 0], - easing: 'easeOutElastic', - duration: 1100, + timelineOffset: '+=100', }, { targets: elementRef, - translateY: [-120, 0], - opacity: [0, 1], - scale: [2, 1], + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { + value: [0, -30], + duration: 100, + easing: 'cubicBezier(0.225, 1, 0.915, 0.980)', + }, + { value: 0, duration: 120, easing: 'easeInQuad' }, + ], + scaleX: [ + { value: 0.8, duration: 190, easing: 'easeOutQuad' }, + { value: 1.2, duration: 140, easing: 'easeOutQuad' }, + { value: 1, duration: 100, easing: 'easeOutQuad' }, + ], + scaleY: [ + { value: 1.6, duration: 190, easing: 'easeOutQuad' }, + { value: 0.65, duration: 140, easing: 'easeOutExpo' }, + { value: 1, duration: 700, easing: 'easeOutElastic(1, .4)' }, + ], translateZ: 0, - delay: anime.stagger(80, { easing: 'easeInCirc' }), - duration: 900, - timelineOffset: '-=500', + delay: anime.stagger(25), isChildren: true, + timelineOffset: '-=1000', }, ); From 3f0f1dba773bc1d1cb6e9ea892ef6b47f8bcdc67 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 16 Nov 2023 17:00:16 +0200 Subject: [PATCH 23/64] feat: implement fetching movie details --- src/app/router.tsx | 2 -- src/widgets/MovieDetails/MovieDetails.tsx | 33 +++++++++++------ src/widgets/MovieDetails/hooks/useMovie.ts | 42 ++++------------------ 3 files changed, 29 insertions(+), 48 deletions(-) diff --git a/src/app/router.tsx b/src/app/router.tsx index 59eb954..1aedbce 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,6 +1,5 @@ import { createHashRouter, RouteObject } from 'react-router-dom'; -import loader from '../entities/movie/loader.ts'; import AppLayout from '../pages/AppLayout/AppLayout.tsx'; import NotFound from '../pages/NotFound/NotFound.tsx'; @@ -13,7 +12,6 @@ export const ROUTES: RouteObject[] = [ { path: ':movieId', lazy: () => import('../widgets/MovieDetails/MovieDetails.tsx'), - loader, }, ], }, diff --git a/src/widgets/MovieDetails/MovieDetails.tsx b/src/widgets/MovieDetails/MovieDetails.tsx index e496308..9f3c7aa 100644 --- a/src/widgets/MovieDetails/MovieDetails.tsx +++ b/src/widgets/MovieDetails/MovieDetails.tsx @@ -10,21 +10,34 @@ import Poster from './ui/Poster.tsx'; import Rating from './ui/Rating.tsx'; import Runtime from './ui/Runtime.tsx'; import Title from './ui/Title.tsx'; +import ReactLogo from '../../assets/reactJS-logo.png'; +import { NOT_EXIST } from '../../shared/const/const.ts'; +import useDocumentTitle from '../../shared/hooks/useDocumentTitle.ts'; +import convertSecsToHrsAndMins from '../../shared/lib/helpers/convertSecsToHrsAndMins.ts'; import FallbackUi from '../../shared/ui/FallbackUi.tsx'; export function Component() { + const { movie, isLoading } = useMovie(); + useDocumentTitle(`Cinemania | ${movie?.Title}`); + + if (isLoading || !movie) return null; + const { - description, + Poster: poster, + Title: title, + Runtime: runtime, + Genre: genre, + Plot: plot, + Year: year, imdbRating, imdbVotes, - poster, - time, - actors, - director, - genre, - title, - year, - } = useMovie(); + Director: director, + Actors: actors, + } = movie; + + const time = convertSecsToHrsAndMins(runtime); + const description = `${plot.slice(0, 150)}...`; + const safePoster = poster === NOT_EXIST ? ReactLogo : poster; return (
- +
{title} diff --git a/src/widgets/MovieDetails/hooks/useMovie.ts b/src/widgets/MovieDetails/hooks/useMovie.ts index f164821..8f8a6c2 100644 --- a/src/widgets/MovieDetails/hooks/useMovie.ts +++ b/src/widgets/MovieDetails/hooks/useMovie.ts @@ -1,44 +1,14 @@ -import { useLoaderData } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; -import ReactLogo from '../../../assets/reactJS-logo.png'; -import { NOT_EXIST } from '../../../shared/const/const.ts'; -import useDocumentTitle from '../../../shared/hooks/useDocumentTitle.ts'; -import convertSecsToHrsAndMins from '../../../shared/lib/helpers/convertSecsToHrsAndMins.ts'; -import { ApiMovieResponse } from '../../../shared/types/types.ts'; +import { useGetMovieQuery } from '../../../entities/movie/api/movieApi.ts'; function useMovie() { - const movie = useLoaderData() as ApiMovieResponse; - useDocumentTitle(`Cinemania | ${movie.Title}`); + const { pathname } = useLocation(); + const id = pathname.slice(1); - const { - Poster, - Title, - Runtime, - Genre, - Plot, - Year, - imdbRating, - imdbVotes, - Director, - Actors, - } = movie; + const { data: movie, isLoading } = useGetMovieQuery(id); - const time = convertSecsToHrsAndMins(Runtime); - const description = `${Plot.slice(0, 150)}...`; - const poster = Poster === NOT_EXIST ? ReactLogo : Poster; - - return { - title: Title, - time, - description, - poster, - genre: Genre, - year: Year, - imdbRating, - imdbVotes, - director: Director, - actors: Actors, - }; + return { movie, isLoading }; } export default useMovie; From 50a14951fd9dea19d492236c3b167301e6f5dbc6 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 16 Nov 2023 18:29:56 +0200 Subject: [PATCH 24/64] refactor: re-export search slice actions --- src/features/Search/model/slice.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/Search/model/slice.ts b/src/features/Search/model/slice.ts index 73cfff8..fd7b2d6 100644 --- a/src/features/Search/model/slice.ts +++ b/src/features/Search/model/slice.ts @@ -1,6 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -const initialState = { +interface IInitialState { + query: string; +} + +const initialState: IInitialState = { query: '', }; @@ -14,4 +18,6 @@ export const searchSlice = createSlice({ }, }); +export const { queryUpdated } = searchSlice.actions; + export const searchReducer = searchSlice.reducer; From e2f31c4cce2adf51f3913b90d3989aa183be71ab Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 16 Nov 2023 19:21:59 +0200 Subject: [PATCH 25/64] refactor: adjust logo animation --- src/widgets/Header/ui/Logo.tsx | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/widgets/Header/ui/Logo.tsx b/src/widgets/Header/ui/Logo.tsx index c19a67e..c1bde15 100644 --- a/src/widgets/Header/ui/Logo.tsx +++ b/src/widgets/Header/ui/Logo.tsx @@ -49,53 +49,49 @@ function Logo() { targets: popCorn, opacity: [0, 1], transformOrigin: ['50% 100% 0px', '50% 100% 0px'], - translateY: [ - { value: [-250, 0], duration: 270, easing: 'easeOutCubic' }, - { value: -10, duration: 120, easing: 'easeInQuad' }, - { value: 0, duration: 200, easing: 'easeInQuad' }, - ], + translateY: [{ value: [-250, 0], duration: 120, easing: 'easeInCubic' }], scaleX: [ { value: [0, 0.8], duration: 120, easing: 'easeOutQuad' }, - { value: 2, duration: 190, easing: 'easeInQuad' }, + { value: 1.7, duration: 150, easing: 'easeInQuad' }, { value: 1, duration: 120, easing: 'easeOutQuad' }, { value: 0.9, duration: 120, easing: 'easeOutQuad' }, { value: 1, duration: 700, easing: 'easeOutElastic' }, ], scaleY: [ { value: 2, duration: 120, easing: 'easeOutQuad' }, - { value: 0.2, duration: 190, easing: 'easeOutQuad' }, + { value: 0.4, duration: 150, easing: 'easeOutQuad' }, { value: 1, duration: 120, easing: 'easeOutCirc' }, { value: 1.2, duration: 120, easing: 'easeOutCirc' }, { value: 1, duration: 700, easing: 'easeOutElastic' }, ], translateZ: 0, - timelineOffset: '+=100', + timelineOffset: '-=100', }, { targets: elementRef, transformOrigin: ['50% 100% 0px', '50% 100% 0px'], translateY: [ { - value: [0, -30], - duration: 100, + value: -30, + duration: 170, easing: 'cubicBezier(0.225, 1, 0.915, 0.980)', }, - { value: 0, duration: 120, easing: 'easeInQuad' }, + { value: 0, duration: 160, easing: 'easeInQuad' }, ], scaleX: [ - { value: 0.8, duration: 190, easing: 'easeOutQuad' }, - { value: 1.2, duration: 140, easing: 'easeOutQuad' }, - { value: 1, duration: 100, easing: 'easeOutQuad' }, + { value: 0.8, duration: 360, easing: 'easeOutQuad' }, + { value: 1.4, duration: 80, easing: 'easeOutQuad' }, + { value: 1, duration: 80, easing: 'easeOutQuad' }, ], scaleY: [ - { value: 1.6, duration: 190, easing: 'easeOutQuad' }, - { value: 0.65, duration: 140, easing: 'easeOutExpo' }, - { value: 1, duration: 700, easing: 'easeOutElastic(1, .4)' }, + { value: 1.8, duration: 360, easing: 'easeOutQuad' }, + { value: 0.4, duration: 80, easing: 'easeOutQuad' }, + { value: 1, duration: 600, easing: 'easeOutElastic(1, .45)' }, ], translateZ: 0, - delay: anime.stagger(25), + delay: anime.stagger(27), isChildren: true, - timelineOffset: '-=1000', + timelineOffset: '-=985', }, ); From 0f775d4e61d1e11fd21122949a0dc0937b1c86ca Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 16 Nov 2023 21:35:02 +0200 Subject: [PATCH 26/64] chore: add temporary .env file --- .env | 2 ++ .gitignore | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..ceb8e42 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_API_URL=https://www.omdbapi.com/ +VITE_API_KEY=dbb72d83 diff --git a/.gitignore b/.gitignore index 0651537..8c5de10 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ dist-ssr *.njsproj *.sln *.sw? -*.env +#*.env From 164f7f1f5261e9c528060753d2e5d47cc0d4524e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 17 Nov 2023 18:48:01 +0200 Subject: [PATCH 27/64] feat: improve logo animation --- src/widgets/Header/ui/Logo.tsx | 355 ++++++++++++++++++++++++++------- 1 file changed, 284 insertions(+), 71 deletions(-) diff --git a/src/widgets/Header/ui/Logo.tsx b/src/widgets/Header/ui/Logo.tsx index c1bde15..cd0f1d6 100644 --- a/src/widgets/Header/ui/Logo.tsx +++ b/src/widgets/Header/ui/Logo.tsx @@ -1,23 +1,19 @@ -// 👇 letter order will never change so we can use array index as a key -/* eslint-disable react/no-array-index-key */ - -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import anime from 'animejs/lib/anime.es'; -import useAnimeTimeline from '../../../shared/hooks/useAnimeTimeline.ts'; import LinkWithQuery from '../../../shared/ui/LinkWithQuery.tsx'; -import { LOGO_LETTERS } from '../const/const.ts'; function Logo() { - const popCorn = useRef(null); - const elementRef = useRef(null); + const popCornRef = useRef(null); + const lettersWrapperRef = useRef(null); + + useEffect(() => { + const tl = anime.timeline({}); + anime.set('.dot', { translateY: 250 }); - useAnimeTimeline( - {}, - [], - { - targets: elementRef, + tl.add({ + targets: '[data-animation="letter"]', transformOrigin: ['50% 100% 0px', '50% 100% 0px'], translateY: [ { @@ -43,70 +39,287 @@ function Logo() { ], translateZ: 0, delay: anime.stagger(120, { easing: 'easeInCirc' }), - isChildren: true, - }, - { - targets: popCorn, - opacity: [0, 1], - transformOrigin: ['50% 100% 0px', '50% 100% 0px'], - translateY: [{ value: [-250, 0], duration: 120, easing: 'easeInCubic' }], - scaleX: [ - { value: [0, 0.8], duration: 120, easing: 'easeOutQuad' }, - { value: 1.7, duration: 150, easing: 'easeInQuad' }, - { value: 1, duration: 120, easing: 'easeOutQuad' }, - { value: 0.9, duration: 120, easing: 'easeOutQuad' }, - { value: 1, duration: 700, easing: 'easeOutElastic' }, - ], - scaleY: [ - { value: 2, duration: 120, easing: 'easeOutQuad' }, - { value: 0.4, duration: 150, easing: 'easeOutQuad' }, - { value: 1, duration: 120, easing: 'easeOutCirc' }, - { value: 1.2, duration: 120, easing: 'easeOutCirc' }, - { value: 1, duration: 700, easing: 'easeOutElastic' }, - ], - translateZ: 0, - timelineOffset: '-=100', - }, - { - targets: elementRef, - transformOrigin: ['50% 100% 0px', '50% 100% 0px'], - translateY: [ + }) + .add( { - value: -30, - duration: 170, - easing: 'cubicBezier(0.225, 1, 0.915, 0.980)', + targets: '.dot', + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { value: -40, duration: 600, easing: 'easeOutCubic' }, + { value: 0, duration: 1000, easing: 'easeOutElastic(1, .3)' }, + ], + scaleY: [ + { value: 5, duration: 300, easing: 'easeOutCubic' }, + { value: 0.8, duration: 300, easing: 'easeInCubic' }, + { value: 3, duration: 100, easing: 'easeOutCirc' }, + { value: 0.6, duration: 100, easing: 'easeOutCirc' }, + { value: 1, duration: 1000, easing: 'easeOutElastic' }, + ], + scaleX: [ + { value: 0.5, duration: 300, easing: 'easeOutCirc' }, + { value: 1.2, duration: 300, easing: 'easeInCirc' }, + { value: 0.5, duration: 100, easing: 'easeOutCirc' }, + { value: 1.2, duration: 100, easing: 'easeInCirc' }, + { value: 1, duration: 1200, easing: 'easeOutElastic' }, + ], + opacity: [{ value: [0, 1], duration: 600, easing: 'linear' }], + easing: 'easeInCirc', + delay: anime.stagger(200), }, - { value: 0, duration: 160, easing: 'easeInQuad' }, - ], - scaleX: [ - { value: 0.8, duration: 360, easing: 'easeOutQuad' }, - { value: 1.4, duration: 80, easing: 'easeOutQuad' }, - { value: 1, duration: 80, easing: 'easeOutQuad' }, - ], - scaleY: [ - { value: 1.8, duration: 360, easing: 'easeOutQuad' }, - { value: 0.4, duration: 80, easing: 'easeOutQuad' }, - { value: 1, duration: 600, easing: 'easeOutElastic(1, .45)' }, - ], - translateZ: 0, - delay: anime.stagger(27), - isChildren: true, - timelineOffset: '-=985', - }, - ); + 600, + ) + .add( + { + targets: '.letter-i', + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + scaleY: [ + { value: 0.4, duration: 120, easing: 'easeOutCirc' }, + { value: 1, duration: 120, easing: 'easeOutCirc' }, + ], + scaleX: [ + { value: 1.4, duration: 100, easing: 'easeOutCirc' }, + { value: 1, duration: 300, easing: 'easeOutCirc' }, + ], + delay: anime.stagger(200), + }, + '-=1470', + ) + .add( + { + targets: popCornRef.current, + opacity: [0, 1], + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { value: [-250, 0], duration: 120, easing: 'easeInCubic' }, + ], + scaleX: [ + { value: [0, 0.8], duration: 120, easing: 'easeInQuad' }, + { value: 2, duration: 150, easing: 'easeOutQuad' }, + { value: 1, duration: 120, easing: 'easeInQuad' }, + { value: 0.9, duration: 120, easing: 'easeOutQuad' }, + { value: 1, duration: 700, easing: 'easeOutElastic' }, + ], + scaleY: [ + { value: 3, duration: 120, easing: 'easeInQuad' }, + { value: 0.4, duration: 150, easing: 'easeOutQuad' }, + { value: 1, duration: 120, easing: 'easeInCirc' }, + { value: 1.2, duration: 120, easing: 'easeOutCirc' }, + { value: 1, duration: 700, easing: 'easeOutElastic' }, + ], + translateZ: 0, + }, + '-=500', + ) + .add( + { + targets: '[data-animation="letter"]', + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { + value: -40, + duration: 170, + easing: 'cubicBezier(0.225, 1, 0.915, 0.980)', + }, + { value: 0, duration: 160, easing: 'easeInQuad' }, + ], + scaleX: [ + { value: 0.8, duration: 360, easing: 'easeOutQuad' }, + { value: 1.4, duration: 80, easing: 'easeOutQuad' }, + { value: 1, duration: 80, easing: 'easeOutQuad' }, + ], + scaleY: [ + { value: 1.8, duration: 360, easing: 'easeOutQuad' }, + { value: 0.4, duration: 80, easing: 'easeOutQuad' }, + { value: 1, duration: 600, easing: 'easeOutElastic(1, .45)' }, + ], + translateZ: 0, + delay: anime.stagger(30), + }, + '-=930', + ) + .add( + { + targets: '.dot', + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + translateY: [ + { value: -80, duration: 270, easing: 'easeOutQuint' }, + { value: 15, duration: 100, easing: 'easeInQuad' }, + { value: -30, duration: 300, easing: 'easeOutQuad' }, + { value: 12, duration: 140, easing: 'easeInQuad' }, + { value: 0, duration: 1200, easing: 'easeOutElastic(1, 0.3)' }, + ], + scaleX: [ + { value: 0.8, duration: 240, easing: 'easeOutQuad' }, + { value: 1.4, duration: 220, easing: 'easeInQuint' }, + { value: 0.8, duration: 300, easing: 'easeOutQuad' }, + { value: 1.7, duration: 140, easing: 'easeOutQuad' }, + { value: 1, duration: 80, easing: 'easeInQuad' }, + ], + scaleY: [ + { value: 4, duration: 240, easing: 'easeOutQuad' }, + { value: 0.8, duration: 220, easing: 'easeInQuint' }, + { value: 3, duration: 300, easing: 'easeOutQuint' }, + { value: 0.5, duration: 140, easing: 'easeOutQuad' }, + { value: 1, duration: 1200, easing: 'easeOutElastic' }, + ], + delay: anime.stagger(200), + }, + '-=1250', + ) + .add( + { + targets: '.letter-i', + transformOrigin: ['50% 100% 0px', '50% 100% 0px'], + scaleY: [ + { value: 0.6, duration: 120, easing: 'easeOutQuad' }, + { value: 1, duration: 900, easing: 'easeOutElastic(1, 0.3)' }, + ], + scaleX: [ + { value: 1.4, duration: 120, easing: 'easeOutQuad' }, + { value: 1, duration: 200, easing: 'easeOutQuad' }, + ], + delay: anime.stagger(200), + }, + '-=1500', + ); + }, []); return ( -

- - 🍿 +

+ + + + + + + + + + -
- {LOGO_LETTERS.map((letter, i) => ( - - {letter} - - ))} +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +

From d71d810b099b9b83e741a82b674abbbcdd50915a Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 17 Nov 2023 23:00:51 +0200 Subject: [PATCH 28/64] fix: radial hover issue --- src/entities/movie/ui/Movie.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/entities/movie/ui/Movie.tsx b/src/entities/movie/ui/Movie.tsx index 51b6dc9..983b477 100644 --- a/src/entities/movie/ui/Movie.tsx +++ b/src/entities/movie/ui/Movie.tsx @@ -45,10 +45,8 @@ const Movie = memo(function Movie({ className="w-64 animate-springish cursor-pointer overflow-hidden rounded-5xl bg-neutral-950 text-gray-100 transition-all duration-200">
{ - handleMouseMove(e); - onMouseMove(e); - }} + onMouseMove={handleMouseMove} + onMouseEnter={onMouseMove} onMouseLeave={() => { handleMouseOut(); onMouseOut(); From f4aac2593a64b713cefb59691f7b4eba023b979b Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 17 Nov 2023 23:26:03 +0200 Subject: [PATCH 29/64] fix: tooltip initial position --- src/shared/ui/Tooltip.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/shared/ui/Tooltip.tsx b/src/shared/ui/Tooltip.tsx index a1f9774..1ce8bfc 100644 --- a/src/shared/ui/Tooltip.tsx +++ b/src/shared/ui/Tooltip.tsx @@ -10,6 +10,9 @@ interface ITooltipProps { function Tooltip({ innerRef, children }: ITooltipProps) { return createPortal(
{children} From ae48e7e50cd38f66880f039d5cc454fbf53d737e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 17 Nov 2023 23:32:57 +0200 Subject: [PATCH 30/64] feat: replace regular fetch for search with RTK Query --- src/features/Search/Search.tsx | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx index b7e67ad..f9dd8bb 100644 --- a/src/features/Search/Search.tsx +++ b/src/features/Search/Search.tsx @@ -3,13 +3,14 @@ import { RefObject, useCallback, useRef } from 'react'; import LocomotiveScroll from 'locomotive-scroll'; import { ENTER_KEY, ESCAPE_KEY } from './const/const.ts'; -import useSearch from './hooks/useSearch.ts'; +import { queryUpdated } from './model/slice.ts'; import searchIcon from '../../assets/search.svg'; import { DEFAULT_PAGE, LOCAL_STORAGE_SEARCH_QUERY, SCROLL_TOP_DURATION, } from '../../shared/const/const.ts'; +import useAppDispatch from '../../shared/hooks/useAppDispatch.ts'; import useKey from '../../shared/hooks/useKey.ts'; import useLocalStorageState from '../../shared/hooks/useLocalStorageState.ts'; import useUrl from '../../shared/hooks/useUrl.ts'; @@ -26,21 +27,14 @@ function Search({ scroll }: IMovieListProps) { LOCAL_STORAGE_SEARCH_QUERY, ); const inputRef = useRef(null); - const { fetchMovies, query: currQuery, updateQuery } = useSearch(); const { setUrl } = useUrl(); + const dispatch = useAppDispatch(); - const handleSearch = useCallback( - async (newQuery: string) => { - if (newQuery === currQuery) return; - - setUrl(urlParams.PAGE, DEFAULT_PAGE); - scroll?.current?.scrollTo('top', { duration: SCROLL_TOP_DURATION }); - - fetchMovies(newQuery.trim()); - updateQuery(query); - }, - [currQuery, fetchMovies, query, scroll, setUrl, updateQuery], - ); + const handleSearch = useCallback(() => { + setUrl(urlParams.PAGE, DEFAULT_PAGE); + scroll?.current?.scrollTo('top', { duration: SCROLL_TOP_DURATION }); + dispatch(queryUpdated(query)); + }, [dispatch, query, scroll, setUrl]); function handleEnter() { const isInputFocus = document.activeElement === inputRef.current; @@ -53,7 +47,7 @@ function Search({ scroll }: IMovieListProps) { } if (isInputFocus) { - void handleSearch(query); + handleSearch(); inputRef.current?.blur(); } } @@ -81,7 +75,7 @@ function Search({ scroll }: IMovieListProps) { onChange={(e) => setQuery(e.target.value)} />