diff --git a/src/apis/bookmark.ts b/src/apis/bookmark.ts index 07172e4..bdccd9e 100644 --- a/src/apis/bookmark.ts +++ b/src/apis/bookmark.ts @@ -1,12 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import useAuthStore from '@/stores/authStore'; -import { - InfiniteData, - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { InfiniteData, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import axios, { AxiosError, AxiosResponse } from 'axios'; import { fetchData } from '.'; import { default as apis, urlParams } from './api'; @@ -113,10 +107,17 @@ export const useSaveBookmark = () => { return useMutation({ mutationFn: (data) => fetchData.post(url, data), - onSuccess: () => - queryClient.invalidateQueries({ + onSuccess: () => { + // 북마크 리스트 리패치 + queryClient.removeQueries({ queryKey: [apis.bookmark.bookmark_list], - }), + }); + + // 카테고리 리스트 리패치 + queryClient.removeQueries({ + queryKey: [apis.category.category_list], + }); + }, }); }; @@ -164,22 +165,6 @@ export const fetchUploadImage = async (formData: FormData) => { return; }; -/** - * 북마크 이미지 쌈네일 - */ -export const useGetThumbnailImage = (uuid?: string) => { - const url = apis.fileUpload.thumbnail(uuid ?? ''); - - return useQuery({ - queryKey: [url], - queryFn: () => fetchData.get(url), - select: (res) => res.data.result, - staleTime: 1000 * 60 * 60, - gcTime: 1000 * 60 * 60, - enabled: !!uuid, - }); -}; - interface BookmarkLikeDataType { bookMarkId: number; isFavorite: boolean; diff --git a/src/components/BookmarkCard/index.tsx b/src/components/BookmarkCard/index.tsx index 716056a..787032f 100644 --- a/src/components/BookmarkCard/index.tsx +++ b/src/components/BookmarkCard/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useBookmarkLike, useGetThumbnailImage } from '@/apis/bookmark'; +import { useBookmarkLike } from '@/apis/bookmark'; import { cn } from '@/lib/utils'; import useToastStore from '@/stores/toastStore'; import { MouseEvent, useState } from 'react'; @@ -38,7 +38,9 @@ const BookmarkCard = ({ const { addToast } = useToastStore(); // 썸네일 API 요청 - useGetThumbnailImage(imageUUID); + // const path = apis.fileUpload.thumbnail(imageUUID ?? ''); + + const SEVER_URL = process.env.NEXT_PUBLIC_SERVER_URL; const { mutateAsync: mutateBookmarkLike } = useBookmarkLike(); const [isLike, setIsLike] = useState(isFavorite); @@ -80,7 +82,16 @@ const BookmarkCard = ({ 'group-hover:shadow-layer group-hover:-translate-y-8 transition-all duration-200', ])} > - {representImageUrl ? ( + {imageUUID ? ( + // 등록한 썸네일 이미지 + 썸네일 + ) : representImageUrl ? ( + // url 썸네일 이미지 ) : ( + // Default 이미지 { const [checked, setChecked] = useState(false); - const handleChangeChecked = (checked: boolean) => { + const handleChangeChecked = (e: ChangeEvent) => { + const checked = e.target.checked; + setChecked(checked); }; diff --git a/src/components/common/Check/index.tsx b/src/components/common/Check/index.tsx index 012c0e0..4ace719 100644 --- a/src/components/common/Check/index.tsx +++ b/src/components/common/Check/index.tsx @@ -5,11 +5,18 @@ import Icon from '../Icon'; export interface ICheck { name?: string; checked?: boolean; - onChange?: (checked: boolean) => void; + onChange?: (e: ChangeEvent) => void; defaultChecked?: boolean; + value?: string | number; } -const Check = ({ name, checked: controlledChecked, onChange, defaultChecked = false }: ICheck) => { +const Check = ({ + name, + checked: controlledChecked, + onChange, + defaultChecked = false, + value, +}: ICheck) => { const isControlled = !!controlledChecked; const [checked, setChecked] = useState(defaultChecked); @@ -19,7 +26,7 @@ const Check = ({ name, checked: controlledChecked, onChange, defaultChecked = fa if (!isControlled) { setChecked(checked); } - onChange?.(checked); + onChange?.(e); }; const isChecked = isControlled ? controlledChecked : checked; @@ -35,7 +42,14 @@ const Check = ({ name, checked: controlledChecked, onChange, defaultChecked = fa > {isChecked && } - + ); diff --git a/src/components/common/Modal/ModalLayout.tsx b/src/components/common/Modal/ModalLayout.tsx index 93e0433..ae89f6f 100644 --- a/src/components/common/Modal/ModalLayout.tsx +++ b/src/components/common/Modal/ModalLayout.tsx @@ -19,7 +19,7 @@ const ModalLayout = ({ children }: IModal) => { return (
diff --git a/src/components/common/Modal/ui/BookmarkModal.tsx b/src/components/common/Modal/ui/BookmarkModal.tsx index 133b3b8..c8450f6 100644 --- a/src/components/common/Modal/ui/BookmarkModal.tsx +++ b/src/components/common/Modal/ui/BookmarkModal.tsx @@ -6,18 +6,23 @@ import { fetchUploadImage, useSaveBookmark, } from '@/apis/bookmark'; +import { useCategoryList, useSaveCategory } from '@/apis/category'; import useDragUpload from '@/hooks/useDragUpload'; import useEscKeyModalEvent from '@/hooks/useEscKeyModalEvent'; +import useOnClickOutside from '@/hooks/useOnClickOutside'; import { cn } from '@/lib/utils'; import useModalStore from '@/stores/modalStore'; import useToastStore from '@/stores/toastStore'; import { useFormik } from 'formik'; import { debounce } from 'lodash'; -import { ChangeEvent, useCallback, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'; import * as yup from 'yup'; import { Button } from '../../Button'; +import Check from '../../Check'; import Icon from '../../Icon'; +import { Option } from '../../Option'; import { Select } from '../../Select'; +import { Tag } from '../../Tag'; import { Textfield } from '../../Textfield'; import ModalPortal from '../ModalPortal'; @@ -31,17 +36,18 @@ const BookmarkModal = () => { useEscKeyModalEvent('bookmarkModal'); const { mutateAsync: mutateSaveBookmark } = useSaveBookmark(); + const { data: categoryData } = useCategoryList(); + const { mutateAsync: mutateSaveCategory } = useSaveCategory(); const { - files, + file, isDragged, handleUploadFile, handleDragenter, handleDragover, handleDragleave, handleDrop, - handleDeleteFile, - } = useDragUpload({ maxNum: 1, extension: ['png', 'jpg', 'jpeg'] }); + } = useDragUpload({ extension: ['png', 'jpg', 'jpeg'] }); const formik = useFormik({ initialValues: { @@ -54,6 +60,7 @@ const BookmarkModal = () => { siteName: '', }, validationSchema: yup.object({ + memo: yup.string().max(200, '200자 이내로 작성해주세요.'), url: yup.string().required('URL을 입력해 주세요.'), }), onSubmit: (values) => { @@ -61,7 +68,32 @@ const BookmarkModal = () => { }, }); + const categoryRef = useRef(null); + + useOnClickOutside([categoryRef], () => setIsFocus(false)); + + const [isFocus, setIsFocus] = useState(false); const [category, setCategory] = useState(''); + const [selectCategory, setSelectCategory] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + + // 카테고리 선태 + const handleSelectCategory = async () => { + const res = await mutateSaveCategory(category); + + handleCheckCategory(category, res.data.result); + setCategory(''); + }; + + // 카테고리 체크 + const handleCheckCategory = (label: string, value: number) => { + setSelectCategory((prev) => [...prev, { label, value }]); + }; + + // 카테고리 삭제 + const handleRemoveCategory = (id: number) => { + setSelectCategory((prev) => prev.filter((item) => item.value !== id)); + }; const handleChangeUrl = (e: ChangeEvent) => { const value = e.target.value; @@ -70,34 +102,58 @@ const BookmarkModal = () => { delayedHTML(value); }; + const handleChangeCategory = (e: ChangeEvent) => { + const value = Number(e.target.value); + const checked = e.target.checked; + + if (checked) { + const findCategory = categoryData?.find((item) => item.categoryId === value); + + if (findCategory) { + handleCheckCategory(findCategory.categoryName, findCategory.categoryId); + } + } else { + handleRemoveCategory(value); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps const delayedHTML = useCallback( - debounce((url) => getMetaTag(url), 1000), + debounce((url) => getMetaTag(url), 600), [], ); + // 메타 데이터 가져오기 const getMetaTag = async (url: string) => { + setIsLoading(true); + const result = await fetchGetMetaData(url); if (result) { const meta = result.meta; formik.setFieldValue('title', meta.title); - formik.setFieldValue('memo', meta.description); + formik.setFieldValue('memo', meta.description.substring(0, 200)); formik.setFieldValue('favicon', meta.favicon); formik.setFieldValue('representImageUrl', meta.image); formik.setFieldValue('siteName', meta.siteName); } else { formik.setFieldError('url', '등록할 수 없는 URL이에요.'); } + + const MIN_LAZY_TIME = 1000; + + setTimeout(() => { + setIsLoading(false); + }, MIN_LAZY_TIME); }; // 북마크 등록 const saveBookmark = async (values: ISaveBookmarkDataType) => { const formData = new FormData(); - if (files.length > 0) { - formData.append('file', files[0].originFile); + if (file) { + formData.append('file', file.originFile); const res = await fetchUploadImage(formData); if (res?.message === 'OK') { @@ -107,6 +163,8 @@ const BookmarkModal = () => { } } + values.categoryIds = selectCategory.map((item) => item.value); + mutateSaveBookmark(values).then(() => { addToast('북마크가 추가되었어요.', 'success'); @@ -114,6 +172,15 @@ const BookmarkModal = () => { }); }; + const categoryDataFormat = useMemo(() => { + return ( + categoryData?.map((item) => ({ + ...item, + checked: !!selectCategory.find((cat) => cat.value === item.categoryId) || !item.categoryId, + })) ?? [] + ); + }, [categoryData, selectCategory]); + return (
@@ -122,16 +189,59 @@ const BookmarkModal = () => {
- + {/* 카테고리 영역 START */} +
+ + {isFocus && ( +
+ {!categoryData?.find((item) => item.categoryName === category) && ( + + )} + {categoryDataFormat?.map((item) => ( + + ))} +
+ )} +
+ {/* 카테고리 영역 END*/} { placeholder='ex) packit' value={formik.values.title} onChange={formik.handleChange} + isDisabled={isLoading} > 이름 @@ -161,9 +272,10 @@ const BookmarkModal = () => { 메모 @@ -175,38 +287,47 @@ const BookmarkModal = () => {
- {files.length === 0 ? ( -
diff --git a/src/components/common/Select/modules/SelectStateContext.tsx b/src/components/common/Select/modules/SelectStateContext.tsx index e369ec6..8b404ec 100644 --- a/src/components/common/Select/modules/SelectStateContext.tsx +++ b/src/components/common/Select/modules/SelectStateContext.tsx @@ -1,10 +1,11 @@ -import { ChangeEvent, createContext, useContext } from 'react'; +import { ChangeEvent, createContext, MouseEvent, useContext } from 'react'; interface DefaultValueState { value: string; placeholder?: string; tagList?: Array<{ id: number; label: string }>; onChange: (e: ChangeEvent) => void; + onClick?: (e: MouseEvent) => void; isDisabled?: boolean; isInvalid?: boolean; } diff --git a/src/components/common/Select/ui/SelectInputWrapper.tsx b/src/components/common/Select/ui/SelectInputWrapper.tsx index e1471fa..3d94c37 100644 --- a/src/components/common/Select/ui/SelectInputWrapper.tsx +++ b/src/components/common/Select/ui/SelectInputWrapper.tsx @@ -7,10 +7,10 @@ import { useSelectState } from '../modules/SelectStateContext'; export interface ISelectInputWrapper extends PropsWithChildren {} const SelectInputWrapper = ({ children }: ISelectInputWrapper) => { - const { isDisabled, isInvalid } = useSelectState(); + const { isDisabled, isInvalid, onClick } = useSelectState(); return ( -
+
{children}
diff --git a/src/components/common/Select/ui/SelectMain.tsx b/src/components/common/Select/ui/SelectMain.tsx index 17f8b6a..5cd9763 100644 --- a/src/components/common/Select/ui/SelectMain.tsx +++ b/src/components/common/Select/ui/SelectMain.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, PropsWithChildren } from 'react'; +import { ChangeEvent, MouseEvent, PropsWithChildren } from 'react'; import { SelectContext } from '../modules/SelectStateContext'; export interface ISelectMain extends PropsWithChildren { @@ -7,18 +7,22 @@ export interface ISelectMain extends PropsWithChildren { isDisabled?: boolean; isInvalid?: boolean; onChange: (e: ChangeEvent) => void; + onClick?: (e: MouseEvent) => void; } const SelectMain = ({ value, placeholder, onChange, + onClick, isDisabled = false, isInvalid = false, children, }: ISelectMain) => { return ( - +
{children}
); diff --git a/src/components/common/Tag/ui/TagLabel.tsx b/src/components/common/Tag/ui/TagLabel.tsx index 1020a56..b36ae01 100644 --- a/src/components/common/Tag/ui/TagLabel.tsx +++ b/src/components/common/Tag/ui/TagLabel.tsx @@ -11,7 +11,7 @@ const TagLabel = ({ children }: ITagLabel) => { return {children}; }; -const tagLabelVariants = cva([], { +const tagLabelVariants = cva(['whitespace-nowrap'], { variants: { size: { xs: 'label-xs', diff --git a/src/hooks/useDragUpload.ts b/src/hooks/useDragUpload.ts index d0cb766..ed5f3c4 100644 --- a/src/hooks/useDragUpload.ts +++ b/src/hooks/useDragUpload.ts @@ -1,7 +1,6 @@ import { ChangeEvent, DragEvent, useState } from 'react'; interface Props { - maxNum?: number; extension?: Array; } @@ -14,10 +13,10 @@ interface IFile { originFile: File; } -const useDragUpload = ({ maxNum = 3, extension = [] }: Props) => { +const useDragUpload = ({ extension = [] }: Props) => { const MAX_SIZE = 5 * 1024 * 1024; - const [files, setFiles] = useState>([]); + const [file, setFile] = useState(); const [isDragged, setIsDragged] = useState(false); @@ -29,11 +28,6 @@ const useDragUpload = ({ maxNum = 3, extension = [] }: Props) => { if (!dropFiles) return; - if (dropFiles.length + files.length > maxNum) { - alert(`파일은 최대 ${maxNum}까지 업로드 가능합니다.`); - return; - } - filesUpload(dropFiles); }; const handleUploadFile = (e: ChangeEvent) => { @@ -43,11 +37,6 @@ const useDragUpload = ({ maxNum = 3, extension = [] }: Props) => { if (!inputFiles) return; - if (inputFiles.length + files.length > maxNum) { - alert(`파일은 최대 ${maxNum}까지 업로드 가능합니다.`); - return; - } - filesUpload(inputFiles); }; const handleDragover = (e: DragEvent) => { @@ -93,17 +82,14 @@ const useDragUpload = ({ maxNum = 3, extension = [] }: Props) => { const reader = new FileReader(); reader.onload = (event) => { - setFiles((prev) => [ - ...prev, - { - src: event.target?.result, - key: key + `/${i}`, - name: fileName, - size, - type: fileExtension, - originFile: newFiles[i], - }, - ]); + setFile({ + src: event.target?.result, + key: key + `/${i}`, + name: fileName, + size, + type: fileExtension, + originFile: newFiles[i], + }); }; reader.readAsDataURL(newFiles[i]); } else { @@ -113,12 +99,12 @@ const useDragUpload = ({ maxNum = 3, extension = [] }: Props) => { } }; - const handleDeleteFile = (key: string) => { - setFiles(files.filter((file) => file.key !== key)); + const handleDeleteFile = () => { + setFile(undefined); }; return { - files, + file, isDragged, handleUploadFile, handleDrop,