From 6fa936cd42def5478e1d4f3d7d926eb23cf0ba4a Mon Sep 17 00:00:00 2001 From: Alex Zeng Date: Sun, 21 Jul 2024 09:43:47 +1200 Subject: [PATCH 1/4] feat: add refresh icon button to the top-right image --- .../shared/DisplayRandomPicture.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/components/shared/DisplayRandomPicture.tsx b/src/components/shared/DisplayRandomPicture.tsx index 3d6a27d..0ef5c1d 100644 --- a/src/components/shared/DisplayRandomPicture.tsx +++ b/src/components/shared/DisplayRandomPicture.tsx @@ -1,9 +1,11 @@ /* 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 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,6 +14,28 @@ import { useClientContext } from '@/hooks/useClientContext'; import SubmitButton from '@/components/shared/SubmitButton'; +const StyledRefreshButton = styled.div` + position: absolute; + right: 0; + top: 0; + margin: 0.5rem; + cursor: pointer; + svg { + width: 20px; + height: 20px; + } + :hover { + svg { + path { + fill: ${purple[500]}; + } + } + .MuiAvatar-circular { + background-color: ${purple[50]}; + } + } +`; + const DisplayRandomPicture = () => { const [imageUrl, setImageUrl] = useState(''); const [loading, setLoading] = useState(true); @@ -59,6 +83,7 @@ const DisplayRandomPicture = () => { justifyContent='center' alignItems='center' spacing={2} + sx={{ position: 'relative', width: '300px', margin: '0 auto' }} > {error &&

{error}

} {imageUrl && ( @@ -88,6 +113,11 @@ const DisplayRandomPicture = () => { Get Another Picture + + + + + {renderAlertBar()} ); From 8c51fb64c21554d0e5e0b8cdd97feb0adfcdd1ee Mon Sep 17 00:00:00 2001 From: Alex Zeng Date: Sun, 21 Jul 2024 12:08:17 +1200 Subject: [PATCH 2/4] feat: add timeout to getApiResponse --- src/utils/shared/get-api-response.ts | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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); } From e7ac4a9a5ea7b6b59ff4bff80f9497b7c4e6a21e Mon Sep 17 00:00:00 2001 From: Alex Zeng Date: Sun, 21 Jul 2024 12:09:10 +1200 Subject: [PATCH 3/4] feat: add a RefreshButton with spiner while loading --- src/app/page.tsx | 2 + .../shared/DisplayRandomPicture.tsx | 105 ++++++++++++------ 2 files changed, 73 insertions(+), 34 deletions(-) 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 0ef5c1d..6cb18d7 100644 --- a/src/components/shared/DisplayRandomPicture.tsx +++ b/src/components/shared/DisplayRandomPicture.tsx @@ -3,6 +3,7 @@ 'use client'; 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'; @@ -14,45 +15,37 @@ import { useClientContext } from '@/hooks/useClientContext'; import SubmitButton from '@/components/shared/SubmitButton'; -const StyledRefreshButton = styled.div` - position: absolute; - right: 0; - top: 0; - margin: 0.5rem; - cursor: pointer; - svg { - width: 20px; - height: 20px; - } - :hover { - svg { - path { - fill: ${purple[500]}; - } - } - .MuiAvatar-circular { - background-color: ${purple[50]}; - } - } -`; +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({ @@ -60,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; @@ -96,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; From 0a525f72834ddfeccf6dad5929373eaaa0cee95d Mon Sep 17 00:00:00 2001 From: Alex Zeng Date: Sun, 21 Jul 2024 14:35:54 +1200 Subject: [PATCH 4/4] feat: add NextJs14-mui5.gif --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) 🚘🚘🚘