diff --git a/README.md b/README.md index 0ce0fff..c36b92c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ If you prefer Tailwind css, check this: [Tailwind-CSS-Version](https://github.co ## Demo -[](https://mui-nextjs-ts.vercel.app) +[](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘 diff --git a/src/app/page.tsx b/src/app/page.tsx index 0ad23b4..7aea28e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,10 +14,12 @@ const loadDataFromApi = async (slug?: string) => { getApiResponse({ apiEndpoint: 'https://registry.npmjs.org/react/latest', revalidate: 60 * 60 * 24, // 24 hours cache + timeout: 5000, // 5 seconds }), getApiResponse({ apiEndpoint: 'https://registry.npmjs.org/next/latest', revalidate: 0, // no cache + timeout: 5000, // 5 seconds }), ]); diff --git a/src/components/shared/DisplayRandomPicture.tsx b/src/components/shared/DisplayRandomPicture.tsx index 3d6a27d..6cb18d7 100644 --- a/src/components/shared/DisplayRandomPicture.tsx +++ b/src/components/shared/DisplayRandomPicture.tsx @@ -1,9 +1,12 @@ /* eslint-disable @next/next/no-img-element */ 'use client'; -import { Send } from '@mui/icons-material'; +import styled from '@emotion/styled'; +import { Autorenew, Send } from '@mui/icons-material'; +import { css, keyframes } from '@mui/material'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; +import { purple } from '@mui/material/colors'; import Stack from '@mui/material/Stack'; import React, { useEffect, useState } from 'react'; @@ -12,23 +15,37 @@ import { useClientContext } from '@/hooks/useClientContext'; import SubmitButton from '@/components/shared/SubmitButton'; +import { getApiResponse } from '@/utils/shared/get-api-response'; + const DisplayRandomPicture = () => { const [imageUrl, setImageUrl] = useState(''); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const { fetchCount, updateClientCtx } = useClientContext(); const { setAlertBarProps, renderAlertBar } = useAlertBar(); const renderCountRef = React.useRef(0); const fetchRandomPicture = async () => { + if (loading) { + setAlertBarProps({ + message: 'Please wait for the current fetch to complete', + severity: 'warning', + }); + return; + } setLoading(true); setError(''); try { - const response = await fetch('https://picsum.photos/300/150'); - if (!response.ok) { - throw new Error('Error fetching the image'); + const response = await getApiResponse({ + apiEndpoint: 'https://picsum.photos/300/160', + timeout: 5001, + }); + + if (!response?.url) { + throw new Error('Error fetching the image, no response url'); } + setImageUrl(response.url); updateClientCtx({ fetchCount: fetchCount + 1 }); setAlertBarProps({ @@ -36,18 +53,22 @@ const DisplayRandomPicture = () => { severity: 'info', }); } catch (error) { - setError('Error fetching the image'); + const errorMsg = + error instanceof Error ? error.message : 'Error fetching the image'; + + setError(errorMsg); setAlertBarProps({ - message: 'Error fetching the image', + message: errorMsg, severity: 'error', }); + setLoading(false); } finally { setLoading(false); } }; useEffect(() => { - if (renderCountRef.current === 0) { + if (renderCountRef.current === 0 && !loading) { fetchRandomPicture(); } renderCountRef.current += 1; @@ -59,6 +80,7 @@ const DisplayRandomPicture = () => { justifyContent='center' alignItems='center' spacing={2} + sx={{ position: 'relative', width: '300px', margin: '0 auto' }} > {error &&

{error}

} {imageUrl && ( @@ -71,7 +93,7 @@ const DisplayRandomPicture = () => { )}
{loading && Loading...} Component Render Count:{' '} - {renderCountRef.current} + {renderCountRef.current + 1}
{ Get Another Picture + {imageUrl && ( + + + + + + )} {renderAlertBar()} ); }; +const spin = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; +const StyledRefreshButton = styled.div<{ loading?: boolean }>` + position: absolute; + right: 0; + top: 0; + margin: 0.5rem !important; + pointer-events: ${({ loading }) => (loading ? 'none' : 'auto')}; + opacity: ${({ loading }) => (loading ? '0.6' : '1')}; + cursor: ${({ loading }) => (loading ? 'not-allowed' : 'pointer')}; + svg { + width: 20px; + height: 20px; + animation: ${({ loading }) => + loading + ? css` + ${spin} 2s linear infinite + ` + : 'none'}; + } + :hover { + svg { + path { + fill: ${purple[500]}; + } + } + .MuiAvatar-circular { + background-color: ${purple[50]}; + } + } +`; + export default DisplayRandomPicture; diff --git a/src/utils/shared/get-api-response.ts b/src/utils/shared/get-api-response.ts index c4cb92d..45e70e4 100644 --- a/src/utils/shared/get-api-response.ts +++ b/src/utils/shared/get-api-response.ts @@ -1,21 +1,40 @@ import { IS_PROD } from '@/constants'; import { consoleLog } from '@/utils/shared/console-log'; +/** + * Makes an API request and returns the response data. + * + * @param apiEndpoint - The API endpoint URL. + * @param requestData - The request data to be sent in the request body. + * @param method - The HTTP method for the request (default: 'GET'). + * @param revalidate - The time in seconds to cache the data (default: 3600 seconds in production, 120 seconds otherwise). + * @param headers - The headers to be included in the request. + * @param timeout - The timeout in milliseconds for the request (default: 100000 = 100 seconds). + * @returns The response data from the API. + * @throws An error if the API request fails or times out. + */ export const getApiResponse = async ({ apiEndpoint, requestData, method = 'GET', revalidate = IS_PROD ? 3600 : 120, // cache data in seconds headers, + timeout = 100000, // 100 seconds }: { apiEndpoint: string; requestData?: BodyInit; method?: 'POST' | 'GET' | 'PUT' | 'DELETE'; revalidate?: number; headers?: HeadersInit; + timeout?: number; }) => { try { const startTime = Date.now(); + const controller = new AbortController(); + const signal = controller.signal; + + const timeoutId = setTimeout(() => controller.abort(), timeout); + const response = await fetch(apiEndpoint, { method, body: requestData, @@ -23,6 +42,7 @@ export const getApiResponse = async ({ next: { revalidate, }, + signal, }); if (!response.ok) { consoleLog('🚀 Debug getApiResponse requestData:', requestData); @@ -38,9 +58,18 @@ export const getApiResponse = async ({ duration > 2000 ? '💔' : '-' } ${apiEndpoint}` ); - + clearTimeout(timeoutId); + // if is not valid JSON, return response + if (!response.headers.get('content-type')?.includes('application/json')) { + return response as T; + } return (await response.json()) as T; } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + 'Fetch request timed out: ' + (timeout / 1000).toFixed(1) + ' s' + ); + } consoleLog('getApiResponse error:', error); }