Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 업로드 및 validation 로직 리팩토링 등 #163

Merged
merged 5 commits into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/app/(root)/(routes)/(home)/components/CategorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ const CategorySection = () => {
className="w-8 h-8"
alt={`category-${v.key}`}
src={v.image}
quality={50}
sizes="32px"
loading="eager"
/>
<p className={` w-max ${TYPOGRAPHY.description}`}>{v.value}</p>
Expand Down
43 changes: 20 additions & 23 deletions src/components/domain/image-uploader/ImageUploader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client'

import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { MAX_IMAGE_NUMBER } from '@/constants/image'
import { useToast } from '@/hooks/useToast'
import { postImageFile } from '@/services/images'
import { isNotNull } from '@/utils/isNotNull'
import ImageBlock from './components/ImageBlock'
import UploadBlock from './components/UploadBlock'

Expand All @@ -24,29 +24,25 @@ const ImageUploader = ({
const { toast } = useToast()
const [images, setImages] = useState<string[]>(defaultImages)

async function uploadImages(files: FileList) {
const uploadPromises = Array.from(files).map(async (file) => {
try {
const res = await postImageFile(file)
return res.data
} catch (e) {
toast({
title: '이미지 업로드 실패',
description: '이미지 업로드에 실패했습니다. 다시 시도해주세요.',
})
return null
}
})

const uploadedImages = await Promise.all(uploadPromises)

const successfulUploads = uploadedImages
.filter((imageUrl) => imageUrl != null)
.map((imageUrl) => imageUrl)
.filter(isNotNull)
const uploadImageMutation = useMutation({
mutationFn: postImageFile,
mutationKey: ['postImage'],
onSuccess: (data) => {
setImages((currentImages) => [...currentImages, data.data])
onFilesChanged([...images, data.data])
},
onError: () => {
toast({
title: '이미지 업로드 실패',
description: '이미지 업로드에 실패했습니다. 다시 시도해주세요.',
})
},
})

setImages((images) => [...images, ...successfulUploads])
onFilesChanged([...images, ...successfulUploads])
const uploadImages = (files: FileList) => {
Array.from(files).forEach((file) => {
uploadImageMutation.mutate(file)
})
}

const onUploadHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -74,6 +70,7 @@ const ImageUploader = ({
onUploadHandler={onUploadHandler}
currentPhotoNumber={images.length}
maxPhotoNumber={maxImageNumber}
isUploading={uploadImageMutation.isPending}
/>
{images.map((image, index) => {
const isThumbnail = index === 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ type UploadBlockType = {
onUploadHandler: (_e: React.ChangeEvent<HTMLInputElement>) => void
currentPhotoNumber: number
maxPhotoNumber: number
isUploading: boolean
}

const UploadBlock = ({
onUploadHandler,
currentPhotoNumber = 0,
maxPhotoNumber,
isUploading,
}: UploadBlockType) => {
const inputRef = useRef<HTMLInputElement>(null)

Expand All @@ -35,7 +37,11 @@ const UploadBlock = ({
TYPOGRAPHY.description,
'text-text-secondary-color',
)}
>{`${currentPhotoNumber}/${maxPhotoNumber}`}</span>
>
{isUploading
? `업로드 중`
: `${currentPhotoNumber}/${maxPhotoNumber}`}
</span>
</div>
<Input
ref={inputRef}
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/ThemeProviderContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const ThemeProviderContext = ({ children }: ThemeProviderContextProps) => {
if (!mounted) return null

return (
<ThemeProvider attribute="light" enableSystem={false}>
<ThemeProvider forcedTheme="light" enableSystem={false}>
{children}
</ThemeProvider>
)
Expand Down
137 changes: 69 additions & 68 deletions src/hooks/useValidate.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
'use client'

import { useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import { usePathname, useRouter } from 'next/navigation'
import { useRouter } from 'next/navigation'
import AppPath from '@/config/appPath'
import { Environment } from '@/config/environment'
import apiClient from '@/services/apiClient'
import { getValidateUser, reissueAccessToken } from '@/services/auth/auth'
import type { User } from '@/types/user'
import { useToast } from './useToast'

const useValidate = () => {
const router = useRouter()
const { toast } = useToast()
const pathname = usePathname()

const accessToken = Cookies.get(Environment.tokenName())
const refreshToken = Cookies.get(Environment.refreshTokenName())

const [isLoggedIn, setIsLoggedIn] = useState<boolean>(() => !!accessToken)
const [currentUser, setCurrentUser] = useState<User | null>(() => null)

const handleTokenRefresh = ({
token,
expiresInHours = 1,
}: {
token: string
expiresInHours?: number
}) => {
let expiry = new Date()
expiry.setHours(expiry.getHours() + expiresInHours)
Cookies.set(Environment.tokenName(), token, { expires: expiry })
return token
}

const validateUserQuery = useQuery({
queryKey: ['validate', accessToken],
queryFn: async () => {
const token = accessToken
if (token) {
apiClient.setDefaultHeader('Authorization', token)
return getValidateUser()
if (!accessToken) {
throw new Error('No token found')
}
throw new Error('No token found')
apiClient.setDefaultHeader('Authorization', accessToken)
return getValidateUser()
},
enabled: !!accessToken,
throwOnError: false,
Expand All @@ -52,67 +33,87 @@ const useValidate = () => {
const reissueTokenQuery = useQuery({
queryKey: ['reissueAccessToken', refreshToken],
queryFn: async () => {
if (refreshToken) {
return reissueAccessToken({ refreshToken })
if (!refreshToken) {
throw new Error('No refresh token found')
}
throw new Error('No refresh token found')
return reissueAccessToken({ refreshToken })
},
enabled: !!refreshToken && (validateUserQuery.isError || !accessToken),
throwOnError: false,
})

const updateLoginState = (userInfo: User) => {
setCurrentUser(() => userInfo)
setIsLoggedIn(() => !!userInfo)
/**
*
* @description 재발급 된 토큰을 쿠키에 저장합니다.
*/
const handleTokenRefresh = ({
token,
expiresInHours = 1,
}: {
token?: string
expiresInHours?: number
}) => {
if (!token) return
let expiry = new Date()
expiry.setHours(expiry.getHours() + expiresInHours)
Cookies.set(Environment.tokenName(), token, { expires: expiry })
window.location.reload()
}

useEffect(() => {
if (validateUserQuery.isError) {
Cookies.remove(Environment.tokenName())
if (!refreshToken || reissueTokenQuery.isError) {
router.push(AppPath.login(), { scroll: false })
toast({
title: '인증 에러',
description: '만료되거나 잘못된 토큰입니다. 다시 로그인해주세요.',
variant: 'destructive',
duration: 3000,
})
} else if (reissueTokenQuery.data?.data?.accessToken) {
handleTokenRefresh({
token: reissueTokenQuery.data.data.accessToken,
})
updateLoginState(validateUserQuery.data?.data?.userInfo)
}
}
const showAuthErrorToast = useCallback(() => {
toast({
title: '인증 에러',
description: '만료되거나 잘못된 토큰입니다. 다시 로그인해주세요.',
variant: 'destructive',
duration: 3000,
})
}, [toast])

if (!accessToken && !!refreshToken) {
if (reissueTokenQuery.data?.data?.accessToken) {
handleTokenRefresh({
token: reissueTokenQuery.data.data.accessToken,
})
updateLoginState(validateUserQuery.data?.data?.userInfo)
}
}
/**
* @description: 세션 만료시 로그인 페이지로 이동합니다.
* @description: 리프레시 토큰까지 만료되었을 경우
*/
const handleSessionExpiration = useCallback(() => {
Cookies.remove(Environment.tokenName())
Cookies.remove(Environment.refreshTokenName())
router.push(AppPath.login(), { scroll: false })
showAuthErrorToast()
}, [router, showAuthErrorToast])

if (validateUserQuery.data?.data?.userInfo) {
const userInfo = validateUserQuery.data.data.userInfo
setCurrentUser(() => userInfo)
setIsLoggedIn(() => !!userInfo)
/**
* @description: 토큰 재발급이 필요한 경우, 재발급 후 새로고침합니다.
* @description: 리프레시 토큰까지 만료되었을 경우 handleSessionExpiration 실행
*/
const refreshTokenIfNeeded = useCallback(async () => {
if (!refreshToken || reissueTokenQuery.isError) {
handleSessionExpiration()
} else if (reissueTokenQuery.data?.data?.accessToken) {
const newToken = reissueTokenQuery.data.data.accessToken
handleTokenRefresh({ token: newToken })
}
}, [
validateUserQuery.data?.data?.userInfo,
refreshToken,
reissueTokenQuery.isError,
reissueTokenQuery.data?.data.accessToken,
router,
pathname,
toast,
validateUserQuery.isError,
handleSessionExpiration,
])

useEffect(() => {
if ((!accessToken && refreshToken) || validateUserQuery.isError) {
refreshTokenIfNeeded()
}
}, [
accessToken,
refreshToken,
reissueTokenQuery.isError,
reissueTokenQuery?.data?.data.accessToken,
validateUserQuery.isError,
refreshTokenIfNeeded,
])

return { isLoggedIn, currentUser }
return {
isLoggedIn: !!accessToken,
currentUser: validateUserQuery?.data?.data.userInfo,
}
}

export default useValidate
10 changes: 5 additions & 5 deletions src/services/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ const postImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)

const response = await apiClient.post(
ApiEndPoint.postImageFile(),
formData,
{},
)
const response: Promise<{
code: string
data: string
messages: string
}> = await apiClient.post(ApiEndPoint.postImageFile(), formData, {})

return response
}
Expand Down
2 changes: 1 addition & 1 deletion tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
// Or if using `src` directory:
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: ['class'],
darkMode: ['light'],
theme: {
screens: {
xs: { max: '480px' },
Expand Down