diff --git a/src/App.tsx b/src/App.tsx index 8f334b3..75137f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,10 @@ -import { - Dispatch, - SetStateAction, - createContext, - useEffect, - useState, -} from 'react' +import { Dispatch, SetStateAction, createContext, useState } from 'react' import Footer from './components/UI/footer/Footer' import StreamFeed from './components/streamFeed/StreamFeed' import Navigation from './components/UI/navigation/Navigation' import { StreamProps } from './types/StreamProps' -import { getEnglishLanguageName } from './helper/getEnglishLanguageName' import { getItemFromStorage } from './helper/getItemFromStorage' +import { useDocumentTitle } from './hooks/useDocumentTitle' import { useScreenWidth } from './hooks/useScreenWidth' export const ContextScreenWidth = createContext< @@ -85,11 +79,7 @@ const App = () => { const [inputFocussed, setInputFocussed] = useState(false) const [hideSearch, setHideSearch] = useState(true) - useEffect(() => { - document.title = `Twitch-App | ${getEnglishLanguageName( - language - )} Livestreams${seoSearchText ? ` | ${seoSearchText}` : ''}` - }, [language, seoSearchText]) + useDocumentTitle(language, seoSearchText) return (
diff --git a/src/components/UI/navigation/DesktopSearch.tsx b/src/components/UI/navigation/DesktopSearch.tsx index 7a76293..1205119 100644 --- a/src/components/UI/navigation/DesktopSearch.tsx +++ b/src/components/UI/navigation/DesktopSearch.tsx @@ -1,14 +1,16 @@ -import { forwardRef, useContext, useEffect, useRef } from 'react' +import { forwardRef, useContext, useRef } from 'react' +import './search.css' import { ContextDisableFocusTrap, ContextFocusInput, ContextSearchResults, ContextSearchText, } from '../../../App' -import { SearchProps } from '../../../types/SearchProps' import Icon from '../Icon' import SearchResultSuggestion from './SearchResultSuggestion' -import './search.css' +import { SearchProps } from '../../../types/SearchProps' +import { useFocusInput } from '../../../hooks/useFocusInput' +import { useFocusTrapSearch } from '../../../hooks/useFocusTrapSearch' const DesktopSearch = forwardRef( ( @@ -66,68 +68,14 @@ const DesktopSearch = forwardRef( const buttonRef = useRef(null) const searchResultsRef = useRef(null) - useEffect(() => { - const handleFocusTrap = (e: KeyboardEvent) => { - if ( - searchText.length === 0 || - focusTrapDisabled || - e.key !== 'Tab' - ) - return - - const focusableElements = [ - inputRef?.current, - buttonRef.current, - ...(searchResultsRef.current - ? Array.from( - searchResultsRef.current.querySelectorAll( - 'button' - ) - ) - : []), - ].filter((el) => el !== null) as ( - | HTMLInputElement - | HTMLButtonElement - )[] - - if (focusableElements.length === 0) return - - const firstElement = focusableElements[0] - const lastElement = - focusableElements[focusableElements.length - 1] - - if (e.shiftKey) { - if (document.activeElement === firstElement) { - e.preventDefault() - lastElement.focus() - } - } else { - if (document.activeElement === lastElement) { - e.preventDefault() - firstElement.focus() - } - } - } - - document.addEventListener('keydown', handleFocusTrap) - - return () => { - document.removeEventListener('keydown', handleFocusTrap) - } - }, [ + useFocusTrapSearch( + buttonRef, focusTrapDisabled, inputRef, - searchResults, - searchResultsExpanded, - searchText, - ]) - - useEffect(() => { - if (inputFocussed && inputRef?.current) { - inputRef.current.focus() - setInputFocussed(false) - } - }, [inputFocussed, inputRef, setInputFocussed]) + searchResultsRef, + searchText + ) + useFocusInput(inputFocussed, inputRef, setInputFocussed) return (
( ( @@ -66,62 +68,14 @@ const MobileSearch = forwardRef( const buttonRef = useRef(null) const searchResultsRef = useRef(null) - useEffect(() => { - const handleFocusTrap = (e: KeyboardEvent) => { - if (e.key !== 'Tab' || focusTrapDisabled) return - - const focusableElements = [ - searchMobileRef?.current, - buttonRef.current, - ...(searchResultsRef.current - ? Array.from( - searchResultsRef.current.querySelectorAll( - 'button' - ) - ) - : []), - ].filter((el) => el !== null) as ( - | HTMLInputElement - | HTMLButtonElement - )[] - - if (focusableElements.length === 0) return - - const firstElement = focusableElements[0] - const lastElement = - focusableElements[focusableElements.length - 1] - - if (e.shiftKey) { - if (document.activeElement === firstElement) { - e.preventDefault() - lastElement.focus() - } - } else { - if (document.activeElement === lastElement) { - e.preventDefault() - firstElement.focus() - } - } - } - - document.addEventListener('keydown', handleFocusTrap) - - return () => { - document.removeEventListener('keydown', handleFocusTrap) - } - }, [ + useFocusTrapSearch( + buttonRef, focusTrapDisabled, searchMobileRef, - searchResults, - searchResultsExpanded, - ]) - - useEffect(() => { - if (inputFocussed && searchMobileRef?.current) { - searchMobileRef.current.focus() - setInputFocussed(false) - } - }, [inputFocussed, searchMobileRef, setInputFocussed]) + searchResultsRef, + searchText + ) + useFocusInput(inputFocussed, searchMobileRef, setInputFocussed) return ( <> diff --git a/src/components/UI/navigation/Navigation.tsx b/src/components/UI/navigation/Navigation.tsx index 56e2b3f..f18cc80 100644 --- a/src/components/UI/navigation/Navigation.tsx +++ b/src/components/UI/navigation/Navigation.tsx @@ -1,8 +1,9 @@ -import { useContext, useEffect, useRef, useState } from 'react' +import { useContext, useRef, useState } from 'react' import ButtonIcon from '../ButtonIcon' +import DesktopSearch from './DesktopSearch' +import MobileSearch from './MobileSearch' import { HomeIcon } from './HomeIcon' import { UserIcon } from './UserIcon' -import MobileSearch from './MobileSearch' import { ContextDisableFocusTrap, ContextFilteredStreamData, @@ -14,9 +15,11 @@ import { ContextSearchText, ContextStreamData, } from '../../../App' -import DesktopSearch from './DesktopSearch' import { getSearchFilter } from '../../../helper/getSearchFilter' import { setItemInStorage } from '../../../helper/setItemInStorage' +import { useNavigationScrollY } from '../../../hooks/useNavigationScrollY' +import { useCloseSearchResults } from '../../../hooks/useCloseSearchResults' +import { useHideMobileSearch } from '../../../hooks/useHideMobileSearch' const Navigation = () => { const contextScreenWidth = useContext(ContextScreenWidth) @@ -253,103 +256,24 @@ const Navigation = () => { }, 0) } - useEffect(() => { - let lastScrollY = window.scrollY - let timer: NodeJS.Timeout - - const handleScroll = () => { - if (window.scrollY === 0) { - setNavOpacity('opacity-100') - } else if (window.scrollY < lastScrollY && !blockOpacity) { - setNavOpacity('opacity-75') - } else if (!blockOpacity) { - setNavOpacity('opacity-95') - } - lastScrollY = window.scrollY - - clearTimeout(timer) - if (navOpacity !== 'opacity-100') { - timer = setTimeout(() => { - setNavOpacity('opacity-100') - }, 500) - } - } - - window.addEventListener('scroll', handleScroll) - - return () => { - window.removeEventListener('scroll', handleScroll) - clearTimeout(timer) - } - }, [blockOpacity, navOpacity]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - ((desktopSearchRef.current && - !desktopSearchRef.current.contains(event.target as Node)) || - (mobileSearchRef.current && - !mobileSearchRef.current.contains( - event.target as Node - ))) && - searchResultsExpanded - ) { - event.stopPropagation() - setSearchResultsExpanded(false) - } - } - - const handleEscape = (event: KeyboardEvent) => { - if ( - ((desktopSearchRef.current && - desktopSearchRef.current.contains(event.target as Node)) || - (mobileSearchRef.current && - mobileSearchRef.current.contains( - event.target as Node - ))) && - searchResultsExpanded && - event.key === 'Escape' - ) { - event.stopPropagation() - setSearchResultsExpanded(false) - if (!inputRef?.current?.onfocus) { - if ( - contextScreenWidth === 'MOBILE' || - contextScreenWidth === 'TABLET_SMALL' - ) { - buttonIconRef?.current?.focus() - } else { - userIconRef?.current?.focus() - anchorRef?.current?.focus() - } - } - } - } - - if (searchResultsExpanded) { - document.addEventListener('keydown', handleEscape) - document.addEventListener('mousedown', handleClickOutside) - } else { - document.removeEventListener('keydown', handleEscape) - document.removeEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('keydown', handleEscape) - document.removeEventListener('mousedown', handleClickOutside) - } - }, [contextScreenWidth, searchResultsExpanded]) - - useEffect(() => { - if ( - (contextScreenWidth === 'MOBILE' || - contextScreenWidth === 'TABLET_SMALL') && - inputFocussed - ) { - setHideSearch(false) - setAriaPressed(true) - } - }, [contextScreenWidth, inputFocussed, setHideSearch]) + useNavigationScrollY(blockOpacity, navOpacity, setNavOpacity) + useCloseSearchResults( + anchorRef, + buttonIconRef, + contextScreenWidth, + desktopSearchRef, + inputRef, + mobileSearchRef, + searchResultsExpanded, + setSearchResultsExpanded, + userIconRef + ) + useHideMobileSearch( + contextScreenWidth, + inputFocussed, + setAriaPressed, + setHideSearch + ) return (
diff --git a/src/components/UI/navigation/SettingsPopup.tsx b/src/components/UI/navigation/SettingsPopup.tsx index dec679f..be53217 100644 --- a/src/components/UI/navigation/SettingsPopup.tsx +++ b/src/components/UI/navigation/SettingsPopup.tsx @@ -1,4 +1,4 @@ -import { FC, useContext, useEffect, useRef, useState } from 'react' +import { FC, useContext, useRef, useState } from 'react' import { ContextLanguage, ContextSEOSearchText, @@ -12,6 +12,8 @@ import Icon from '../Icon' import ProfilePicture from './ProfilePicture' import { getLanguageIndex } from '../../../helper/getLanguageIndex' import { setItemInStorage } from '../../../helper/setItemInStorage' +import { useCloseSettingsPopup } from '../../../hooks/useCloseSettingsPopup' +import { useSettingsPopupFocusTrap } from '../../../hooks/useSettingsPopupFocusTrap' type SettingsPopupProps = { handleButtonKeyDown: (e: React.KeyboardEvent) => void @@ -176,61 +178,12 @@ const SettingsPopup: FC = ({ window.location.reload() } - useEffect(() => { - const handleEscape = (event: KeyboardEvent) => { - if ( - popupLanguageRef.current && - !popupLanguageRef.current.contains(event.target as Node) && - filterLanguageExpanded && - event.key === 'Escape' - ) { - event.stopPropagation() - setFilterLanguageExpanded(false) - } - } - - if (filterLanguageExpanded) { - document.addEventListener('keydown', handleEscape) - } else { - document.removeEventListener('keydown', handleEscape) - } - - return () => { - document.removeEventListener('keydown', handleEscape) - } - }, [filterLanguageExpanded, setFilterLanguageExpanded]) - - useEffect(() => { - if (filterLanguageExpanded) return - - const buttons = popupRef.current?.querySelectorAll('button') - if (!buttons || buttons.length === 0) return - - const firstButton = buttons[0] - const lastButton = buttons[buttons.length - 1] - - const handleFocusTrap = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return - - if (e.shiftKey) { - if (document.activeElement === firstButton) { - e.preventDefault() - lastButton.focus() - } - } else { - if (document.activeElement === lastButton) { - e.preventDefault() - firstButton.focus() - } - } - } - - document.addEventListener('keydown', handleFocusTrap) - - return () => { - document.removeEventListener('keydown', handleFocusTrap) - } - }, [filterLanguageExpanded, popupRef]) + useCloseSettingsPopup( + filterLanguageExpanded, + popupLanguageRef, + setFilterLanguageExpanded + ) + useSettingsPopupFocusTrap(filterLanguageExpanded, popupRef) return (
>] | undefined @@ -61,20 +64,7 @@ export const UserIcon: FC = ({ anchorRef, buttonRef }) => { sessionStorage.setItem('twitch_logged_in', 'true') } - useEffect(() => { - const isLoggedIn = sessionStorage.getItem('twitch_logged_in') - isLoggedIn === 'true' && !user && fetchUser() - const timer = setTimeout( - () => - isLoggedIn === 'false' && - (sessionStorage.removeItem('twitch_logged_in'), - sessionStorage.removeItem('twitch_access_state'), - sessionStorage.removeItem('twitch_access_token'), - sessionStorage.removeItem('twitch_user')), - 0 - ) - return () => clearTimeout(timer) - }, [user]) + useLogUserOut(fetchUser, user) const handleButtonClick = () => { setDropdownActive((prev) => !prev) @@ -98,74 +88,17 @@ export const UserIcon: FC = ({ anchorRef, buttonRef }) => { } } - useEffect(() => { - const getAuthUrl = async () => { - if (state.length) { - try { - const response = await fetch( - `https://twitch-backend.vercel.app/api/auth-url?state=${state}` - ) - const data = await response.json() - setRedirectUrl(data.url) - } catch (error) { - console.error('Error fetching auth URL:', error) - } - } - } - getAuthUrl() - }, [state]) - - useEffect(() => { - if (redirectUrl) { - window.location.href = redirectUrl - } - }, [redirectUrl]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - popupRef.current && - !popupRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setDropdownActive(false) - setFilterLanguageExpanded(false) - } - } - - if (dropdownActive) { - document.addEventListener('mousedown', handleClickOutside) - } else { - document.removeEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [buttonRef, dropdownActive]) - - useEffect(() => { - const handleEscape = (event: KeyboardEvent) => { - if ( - popupRef.current && - !popupRef.current.contains(event.target as Node) && - !filterLanguageExpanded - ) { - setDropdownActive(false) - } - } - - if (dropdownActive && !filterLanguageExpanded) { - document.addEventListener('keydown', handleEscape) - } else { - document.removeEventListener('keydown', handleEscape) - } + useGetAuthUrl(setRedirectUrl, state) + useSetRedirectUrl(redirectUrl || '') - return () => { - document.removeEventListener('keydown', handleEscape) - } - }, [dropdownActive, filterLanguageExpanded]) + useCloseUserIcon( + buttonRef, + dropdownActive, + filterLanguageExpanded, + popupRef, + setDropdownActive, + setFilterLanguageExpanded + ) return ( <> diff --git a/src/components/streamFeed/StreamFeed.tsx b/src/components/streamFeed/StreamFeed.tsx index 63a4a33..2fb0b90 100644 --- a/src/components/streamFeed/StreamFeed.tsx +++ b/src/components/streamFeed/StreamFeed.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect, useState } from 'react' +import { useCallback, useContext, useState } from 'react' import { StreamProps } from '../../types/StreamProps' import SkeletonFeed from '../skeleton/SkeletonFeed' @@ -29,6 +29,7 @@ import { getItemFromStorage } from '../../helper/getItemFromStorage' import { getSearchFilter } from '../../helper/getSearchFilter' import { getStreams } from '../../helper/getStreams' import { setItemInStorage } from '../../helper/setItemInStorage' +import { useLoadStreams } from '../../hooks/useLoadStreams' const bgColors = [ 'bg-gradient-to-tr from-red-400 to-red-800', @@ -181,16 +182,7 @@ const StreamFeed = () => { setStreamData, ]) - useEffect(() => { - loadStreams() - const refresh = setInterval(() => { - loadStreams() - }, 120000) - return () => { - clearInterval(refresh) - } - }, [loadStreams]) - + useLoadStreams(loadStreams) useFocusTrap(error, filteredStreamData) if (error) { diff --git a/src/components/streamFeed/StreamPlayer.tsx b/src/components/streamFeed/StreamPlayer.tsx index bda80be..a8ccdfe 100644 --- a/src/components/streamFeed/StreamPlayer.tsx +++ b/src/components/streamFeed/StreamPlayer.tsx @@ -1,4 +1,7 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' +import { useInitPlayer } from '../../hooks/useInitPlayer' +import { useUpdatePlayerHeight } from '../../hooks/useUpdatePlayerHeight' +import { useSetPlayerHeight } from '../../hooks/useSetPlayerHeight' interface StreamPlayerProps { channel: string @@ -10,59 +13,9 @@ const StreamPlayer: React.FC = ({ channel }) => { const [height, setHeight] = useState(0) const volume = 0.5 - useEffect(() => { - const updateHeight = () => { - if (containerRef.current) { - const width = containerRef.current.offsetWidth - setHeight(Math.round((width * 9) / 16)) - } - } - updateHeight() - window.addEventListener('resize', updateHeight) - return () => window.removeEventListener('resize', updateHeight) - }, []) - - useEffect(() => { - if (height === 0) { - return - } - - const initPlayer = () => { - if (playerRef.current) { - return - } - - if ((window as any).Twitch?.Player) { - const options = { - width: '100%', - height, - channel, - muted: true, - parent: [window.location.hostname], - } - playerRef.current = new (window as any).Twitch.Player( - containerRef.current, - options - ) - playerRef.current.setVolume(volume) - } - } - - if (!(window as any).Twitch) { - const script = document.createElement('script') - script.src = 'https://player.twitch.tv/js/embed/v1.js' - script.addEventListener('load', initPlayer) - document.body.appendChild(script) - } else { - initPlayer() - } - }, [channel, height]) - - useEffect(() => { - if (playerRef.current && height > 0) { - playerRef.current._iframe.style.height = `${height}px` - } - }, [height]) + useUpdatePlayerHeight(containerRef, setHeight) + useInitPlayer(channel, containerRef, height, playerRef, volume) + useSetPlayerHeight(height, playerRef) return
} diff --git a/src/components/streamFeed/StreamProfilePicture.tsx b/src/components/streamFeed/StreamProfilePicture.tsx index f932be8..bed8d7e 100644 --- a/src/components/streamFeed/StreamProfilePicture.tsx +++ b/src/components/streamFeed/StreamProfilePicture.tsx @@ -1,4 +1,4 @@ -import { FC, useContext, useEffect, useState } from 'react' +import { FC, useContext, useState } from 'react' import { ContextDisableFocusTrap, @@ -10,9 +10,9 @@ import { ContextStreamData, } from '../../App' import { getImage } from '../../helper/getImage' -import { getProfilePicture } from '../../helper/getProfilePicture' import { getSearchFilter } from '../../helper/getSearchFilter' import { setItemInStorage } from '../../helper/setItemInStorage' +import { useFetchImageUrl } from '../../hooks/useFetchImageUrl' type StreamProfilePictureProps = { isHeroPicture?: boolean @@ -90,21 +90,7 @@ const StreamProfilePicture: FC = ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_searchResults, setSearchResults] = contextSearchResults - useEffect(() => { - const fetchImageUrl = async () => { - try { - const data = await getProfilePicture(user_id || '') - if (!data) { - throw new Error() - } else { - setImageUrl(data) - } - } catch (error: any) {} - } - fetchImageUrl() - - return () => {} - }, [user_id]) + useFetchImageUrl(setImageUrl, user_id) const handleClick = () => { setFilteredStreamData({ diff --git a/src/hooks/useCloseSearchResults.ts b/src/hooks/useCloseSearchResults.ts new file mode 100644 index 0000000..28684c6 --- /dev/null +++ b/src/hooks/useCloseSearchResults.ts @@ -0,0 +1,80 @@ +import { useEffect } from 'react' + +export const useCloseSearchResults = ( + anchorRef: React.RefObject, + buttonIconRef: React.RefObject, + contextScreenWidth: 'MOBILE' | 'TABLET_SMALL' | 'TABLET' | 'DESKTOP', + desktopSearchRef: React.RefObject, + inputRef: React.RefObject, + mobileSearchRef: React.RefObject, + searchResultsExpanded: boolean, + setSearchResultsExpanded: (value: React.SetStateAction) => void, + userIconRef: React.RefObject +) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + ((desktopSearchRef.current && + !desktopSearchRef.current.contains(event.target as Node)) || + (mobileSearchRef.current && + !mobileSearchRef.current.contains( + event.target as Node + ))) && + searchResultsExpanded + ) { + event.stopPropagation() + setSearchResultsExpanded(false) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if ( + ((desktopSearchRef.current && + desktopSearchRef.current.contains(event.target as Node)) || + (mobileSearchRef.current && + mobileSearchRef.current.contains( + event.target as Node + ))) && + searchResultsExpanded && + event.key === 'Escape' + ) { + event.stopPropagation() + setSearchResultsExpanded(false) + if (!inputRef?.current?.onfocus) { + if ( + contextScreenWidth === 'MOBILE' || + contextScreenWidth === 'TABLET_SMALL' + ) { + buttonIconRef?.current?.focus() + } else { + userIconRef?.current?.focus() + anchorRef?.current?.focus() + } + } + } + } + + if (searchResultsExpanded) { + document.addEventListener('keydown', handleEscape) + document.addEventListener('mousedown', handleClickOutside) + } else { + document.removeEventListener('keydown', handleEscape) + document.removeEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('keydown', handleEscape) + document.removeEventListener('mousedown', handleClickOutside) + } + }, [ + anchorRef, + buttonIconRef, + contextScreenWidth, + desktopSearchRef, + inputRef, + mobileSearchRef, + searchResultsExpanded, + setSearchResultsExpanded, + userIconRef, + ]) +} diff --git a/src/hooks/useCloseSettingsPopup.ts b/src/hooks/useCloseSettingsPopup.ts new file mode 100644 index 0000000..726f467 --- /dev/null +++ b/src/hooks/useCloseSettingsPopup.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' + +export const useCloseSettingsPopup = ( + filterLanguageExpanded: boolean, + popupLanguageRef: React.MutableRefObject, + setFilterLanguageExpanded: (value: React.SetStateAction) => void +) => { + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if ( + popupLanguageRef.current && + !popupLanguageRef.current.contains(event.target as Node) && + filterLanguageExpanded && + event.key === 'Escape' + ) { + event.stopPropagation() + setFilterLanguageExpanded(false) + } + } + + if (filterLanguageExpanded) { + document.addEventListener('keydown', handleEscape) + } else { + document.removeEventListener('keydown', handleEscape) + } + + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [filterLanguageExpanded, popupLanguageRef, setFilterLanguageExpanded]) +} diff --git a/src/hooks/useCloseUserIcon.ts b/src/hooks/useCloseUserIcon.ts new file mode 100644 index 0000000..8d5ef5d --- /dev/null +++ b/src/hooks/useCloseUserIcon.ts @@ -0,0 +1,62 @@ +import { SetStateAction, useEffect } from 'react' + +export const useCloseUserIcon = ( + buttonRef: React.RefObject, + dropdownActive: boolean, + filterLanguageExpanded: boolean, + popupRef: React.MutableRefObject, + setDropdownActive: (value: SetStateAction) => void, + setFilterLanguageExpanded: (value: SetStateAction) => void +) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popupRef.current && + !popupRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setDropdownActive(false) + setFilterLanguageExpanded(false) + } + } + + if (dropdownActive) { + document.addEventListener('mousedown', handleClickOutside) + } else { + document.removeEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [ + buttonRef, + dropdownActive, + popupRef, + setDropdownActive, + setFilterLanguageExpanded, + ]) + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if ( + popupRef.current && + !popupRef.current.contains(event.target as Node) && + !filterLanguageExpanded + ) { + setDropdownActive(false) + } + } + + if (dropdownActive && !filterLanguageExpanded) { + document.addEventListener('keydown', handleEscape) + } else { + document.removeEventListener('keydown', handleEscape) + } + + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [dropdownActive, filterLanguageExpanded, popupRef, setDropdownActive]) +} diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts new file mode 100644 index 0000000..979ad3e --- /dev/null +++ b/src/hooks/useDocumentTitle.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react' +import { getEnglishLanguageName } from '../helper/getEnglishLanguageName' + +export const useDocumentTitle = (language: string, seoSearchText: string) => { + useEffect(() => { + document.title = `Twitch-App | ${getEnglishLanguageName( + language + )} Livestreams${seoSearchText ? ` | ${seoSearchText}` : ''}` + }, [language, seoSearchText]) +} diff --git a/src/hooks/useFetchImageUrl.ts b/src/hooks/useFetchImageUrl.ts new file mode 100644 index 0000000..ef3ab6f --- /dev/null +++ b/src/hooks/useFetchImageUrl.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { getProfilePicture } from '../helper/getProfilePicture' + +export const useFetchImageUrl = ( + setImageUrl: (value: React.SetStateAction) => void, + user_id: string +) => { + useEffect(() => { + const fetchImageUrl = async () => { + try { + const data = await getProfilePicture(user_id || '') + if (!data) { + throw new Error() + } else { + setImageUrl(data) + } + } catch (error: any) {} + } + fetchImageUrl() + + return () => {} + }, [setImageUrl, user_id]) +} diff --git a/src/hooks/useFocusInput.ts b/src/hooks/useFocusInput.ts new file mode 100644 index 0000000..0169067 --- /dev/null +++ b/src/hooks/useFocusInput.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react' + +export const useFocusInput = ( + inputFocussed: boolean, + inputRef: React.RefObject | undefined, + setInputFocussed: (value: React.SetStateAction) => void +) => { + useEffect(() => { + if (inputFocussed && inputRef?.current) { + inputRef.current.focus() + setInputFocussed(false) + } + }, [inputFocussed, inputRef, setInputFocussed]) +} diff --git a/src/hooks/useFocusTrapSearch.ts b/src/hooks/useFocusTrapSearch.ts new file mode 100644 index 0000000..0baeecf --- /dev/null +++ b/src/hooks/useFocusTrapSearch.ts @@ -0,0 +1,59 @@ +import { useEffect } from 'react' + +export const useFocusTrapSearch = ( + buttonRef: React.MutableRefObject, + focusTrapDisabled: boolean, + inputRef: React.RefObject | undefined, + searchResultsRef: React.MutableRefObject, + searchText: string +) => { + useEffect(() => { + const handleFocusTrap = (e: KeyboardEvent) => { + if ( + searchText.length === 0 || + e.key !== 'Tab' || + focusTrapDisabled + ) { + return + } + + const focusableElements = [ + inputRef?.current, + buttonRef.current, + ...(searchResultsRef.current + ? Array.from( + searchResultsRef.current.querySelectorAll('button') + ) + : []), + ].filter((element) => element !== null) as ( + | HTMLInputElement + | HTMLButtonElement + )[] + + if (focusableElements.length === 0) { + return + } + + const firstElement = focusableElements[0] + const lastElement = focusableElements[focusableElements.length - 1] + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault() + lastElement.focus() + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault() + firstElement.focus() + } + } + } + + document.addEventListener('keydown', handleFocusTrap) + + return () => { + document.removeEventListener('keydown', handleFocusTrap) + } + }, [buttonRef, focusTrapDisabled, inputRef, searchResultsRef, searchText]) +} diff --git a/src/hooks/useGetAuthUrl.ts b/src/hooks/useGetAuthUrl.ts new file mode 100644 index 0000000..d8fe158 --- /dev/null +++ b/src/hooks/useGetAuthUrl.ts @@ -0,0 +1,23 @@ +import { SetStateAction, useEffect } from 'react' + +export const useGetAuthUrl = ( + setRedirectUrl: (value: SetStateAction) => void, + state: string +) => { + useEffect(() => { + const getAuthUrl = async () => { + if (state.length) { + try { + const response = await fetch( + `https://twitch-backend.vercel.app/api/auth-url?state=${state}` + ) + const data = await response.json() + setRedirectUrl(data.url) + } catch (error) { + console.error('Error fetching auth URL:', error) + } + } + } + getAuthUrl() + }, [setRedirectUrl, state]) +} diff --git a/src/hooks/useHideMobileSearch.ts b/src/hooks/useHideMobileSearch.ts new file mode 100644 index 0000000..33a9161 --- /dev/null +++ b/src/hooks/useHideMobileSearch.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react' + +export const useHideMobileSearch = ( + contextScreenWidth: 'MOBILE' | 'TABLET_SMALL' | 'TABLET' | 'DESKTOP', + inputFocussed: boolean, + setAriaPressed: (value: React.SetStateAction) => void, + setHideSearch: React.Dispatch> +) => { + useEffect(() => { + if ( + (contextScreenWidth === 'MOBILE' || + contextScreenWidth === 'TABLET_SMALL') && + inputFocussed + ) { + setHideSearch(false) + setAriaPressed(true) + } + }, [contextScreenWidth, inputFocussed, setAriaPressed, setHideSearch]) +} diff --git a/src/hooks/useInitPlayer.ts b/src/hooks/useInitPlayer.ts new file mode 100644 index 0000000..c57ec9a --- /dev/null +++ b/src/hooks/useInitPlayer.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react' + +export const useInitPlayer = ( + channel: string, + containerRef: React.MutableRefObject, + height: number, + playerRef: React.MutableRefObject, + volume: number +) => { + useEffect(() => { + if (height === 0) { + return + } + + const initPlayer = () => { + if (playerRef.current) { + return + } + + if ((window as any).Twitch?.Player) { + const options = { + width: '100%', + height, + channel, + muted: true, + parent: [window.location.hostname], + } + playerRef.current = new (window as any).Twitch.Player( + containerRef.current, + options + ) + playerRef.current.setVolume(volume) + } + } + + if (!(window as any).Twitch) { + const script = document.createElement('script') + script.src = 'https://player.twitch.tv/js/embed/v1.js' + script.addEventListener('load', initPlayer) + document.body.appendChild(script) + } else { + initPlayer() + } + }, [channel, containerRef, height, playerRef, volume]) +} diff --git a/src/hooks/useLoadStreams.ts b/src/hooks/useLoadStreams.ts new file mode 100644 index 0000000..82b3dae --- /dev/null +++ b/src/hooks/useLoadStreams.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react' + +export const useLoadStreams = (loadStreams: () => Promise) => { + useEffect(() => { + loadStreams() + const refresh = setInterval(() => { + loadStreams() + }, 120000) + return () => { + clearInterval(refresh) + } + }, [loadStreams]) +} diff --git a/src/hooks/useLogUserOut.ts b/src/hooks/useLogUserOut.ts new file mode 100644 index 0000000..8eb816a --- /dev/null +++ b/src/hooks/useLogUserOut.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import { UserProps } from '../types/UserProps' + +export const useLogUserOut = ( + fetchUser: () => Promise, + user: UserProps | null +) => { + useEffect(() => { + const isLoggedIn = sessionStorage.getItem('twitch_logged_in') + isLoggedIn === 'true' && !user && fetchUser() + const timer = setTimeout( + () => + isLoggedIn === 'false' && + (sessionStorage.removeItem('twitch_logged_in'), + sessionStorage.removeItem('twitch_access_state'), + sessionStorage.removeItem('twitch_access_token'), + sessionStorage.removeItem('twitch_user')), + 0 + ) + return () => clearTimeout(timer) + }, [fetchUser, user]) +} diff --git a/src/hooks/useNavigationScrollY.ts b/src/hooks/useNavigationScrollY.ts new file mode 100644 index 0000000..202152f --- /dev/null +++ b/src/hooks/useNavigationScrollY.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' + +export const useNavigationScrollY = ( + blockOpacity: boolean, + navOpacity: string, + setNavOpacity: (value: React.SetStateAction) => void +) => { + useEffect(() => { + let lastScrollY = window.scrollY + let timer: NodeJS.Timeout + + const handleScroll = () => { + if (window.scrollY === 0) { + setNavOpacity('opacity-100') + } else if (window.scrollY < lastScrollY && !blockOpacity) { + setNavOpacity('opacity-75') + } else if (!blockOpacity) { + setNavOpacity('opacity-95') + } + lastScrollY = window.scrollY + + clearTimeout(timer) + if (navOpacity !== 'opacity-100') { + timer = setTimeout(() => { + setNavOpacity('opacity-100') + }, 500) + } + } + + window.addEventListener('scroll', handleScroll) + + return () => { + window.removeEventListener('scroll', handleScroll) + clearTimeout(timer) + } + }, [blockOpacity, navOpacity, setNavOpacity]) +} diff --git a/src/hooks/useSetPlayerHeight.ts b/src/hooks/useSetPlayerHeight.ts new file mode 100644 index 0000000..a0b1ddd --- /dev/null +++ b/src/hooks/useSetPlayerHeight.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react' + +export const useSetPlayerHeight = ( + height: number, + playerRef: React.MutableRefObject +) => { + useEffect(() => { + if (playerRef.current && height > 0) { + playerRef.current._iframe.style.height = `${height}px` + } + }, [height, playerRef]) +} diff --git a/src/hooks/useSetRedirectUrl.ts b/src/hooks/useSetRedirectUrl.ts new file mode 100644 index 0000000..f4f9f34 --- /dev/null +++ b/src/hooks/useSetRedirectUrl.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react' + +export const useSetRedirectUrl = (redirectUrl: string) => { + useEffect(() => { + if (redirectUrl) { + window.location.href = redirectUrl + } + }, [redirectUrl]) +} diff --git a/src/hooks/useSettingsPopupFocusTrap.ts b/src/hooks/useSettingsPopupFocusTrap.ts new file mode 100644 index 0000000..1b4f71f --- /dev/null +++ b/src/hooks/useSettingsPopupFocusTrap.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' + +export const useSettingsPopupFocusTrap = ( + filterLanguageExpanded: boolean, + popupRef: React.MutableRefObject +) => { + useEffect(() => { + if (filterLanguageExpanded) { + return + } + + const buttons = popupRef.current?.querySelectorAll('button') + if (!buttons || buttons.length === 0) { + return + } + + const firstButton = buttons[0] + const lastButton = buttons[buttons.length - 1] + + const handleFocusTrap = (e: KeyboardEvent) => { + if (e.key !== 'Tab') { + return + } + + if (e.shiftKey) { + if (document.activeElement === firstButton) { + e.preventDefault() + lastButton.focus() + } + } else { + if (document.activeElement === lastButton) { + e.preventDefault() + firstButton.focus() + } + } + } + + document.addEventListener('keydown', handleFocusTrap) + + return () => { + document.removeEventListener('keydown', handleFocusTrap) + } + }, [filterLanguageExpanded, popupRef]) +} diff --git a/src/hooks/useUpdatePlayerHeight.ts b/src/hooks/useUpdatePlayerHeight.ts new file mode 100644 index 0000000..abdcb5e --- /dev/null +++ b/src/hooks/useUpdatePlayerHeight.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react' + +export const useUpdatePlayerHeight = ( + containerRef: React.MutableRefObject, + setHeight: (value: React.SetStateAction) => void +) => { + useEffect(() => { + const updateHeight = () => { + if (containerRef.current) { + const width = containerRef.current.offsetWidth + setHeight(Math.round((width * 9) / 16)) + } + } + updateHeight() + window.addEventListener('resize', updateHeight) + return () => window.removeEventListener('resize', updateHeight) + }, [containerRef, setHeight]) +}