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

[#69] Feat: 짤 상세 모달 get api 구현 및 적용 #108

Merged
merged 19 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
620260a
Feat: 짤 상세 get api함수와 커스텀 훅 구현
hyeonjinan096 Mar 6, 2024
4e53bb2
Feat: 짤 상세 모달에 서버 데이터 적용
hyeonjinan096 Mar 6, 2024
b221816
Merge branch 'main' of https://github.com/zzalmyu/zzalmyu-frontend in…
hyeonjinan096 Mar 6, 2024
939e17a
Rename: copyZzal 함수, downloadZzal 함수 zzalUtils에 통합
hyeonjinan096 Mar 6, 2024
742c6d7
Feat: 짤 상세 모달에 짤 copy, download 기능 추가
hyeonjinan096 Mar 6, 2024
f7b6642
Rename: 서버 tag 데이터 타입 변경에 따른 수정
hyeonjinan096 Mar 6, 2024
dd6523b
Refactor: <>구문을 <Fragment>로 번경
hyeonjinan096 Mar 6, 2024
eb4ebda
Rename: handleClickDeleteButton 핸들러 함수명 수정
hyeonjinan096 Mar 7, 2024
2d79037
Refactor: useGetZzalDetails의 useQuery를 useSuspenseQuery로 변경
hyeonjinan096 Mar 7, 2024
32d0438
Feat: 짤 다운로드시 Loading spinner 기능 구현
hyeonjinan096 Mar 7, 2024
4bdf28b
Merge branch 'main' of https://github.com/zzalmyu/zzalmyu-frontend in…
hyeonjinan096 Mar 7, 2024
fa3cee9
Style: ImageDetailModal 사이즈 수정
hyeonjinan096 Mar 7, 2024
0d634bc
Feat: ImageDetailModal내부 짤 삭제 함수에 debounce 적용
hyeonjinan096 Mar 7, 2024
e18c380
Feat: 짤 삭제 동작 debounce 제거 및 isLoading, isDisabled 설정 추가
hyeonjinan096 Mar 8, 2024
c5432b7
Refactor: 짤 상세 모달 컴포넌트 리팩토링
hyeonjinan096 Mar 8, 2024
e45ae3e
Feat: Spinner 공통 컴포넌트화
hyeonjinan096 Mar 8, 2024
eace9bf
Merge branch 'main' of https://github.com/zzalmyu/zzalmyu-frontend in…
hyeonjinan096 Mar 10, 2024
2a64ba5
Cleanup: 변경된 유틸함수 경로 적용 및 copyZzal 파일 삭제
hyeonjinan096 Mar 10, 2024
cfdba17
Refactor: ctx -> canvasContext 축약어 제거
hyeonjinan096 Mar 10, 2024
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
9 changes: 6 additions & 3 deletions src/apis/zzal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import http from "@/apis/core";
import { GetMyLikedZzalsResponse } from "@/types/zzal.dto";
import { GetZzalResponse } from "@/types/zzal.dto";
import { GetMyLikedZzalsResponse, GetZzalDetailsResponse, GetZzalResponse } from "@/types/zzal.dto";
import http from "./core";

import { PAGINATION_LIMIT } from "@/constants/api";

export const deleteMyZzal = (imageId: number) => {
Expand All @@ -12,6 +12,9 @@ export const getMyLikedZzals = (offset: number) =>
url: `/v1/image/like?page=${offset}&size=${PAGINATION_LIMIT}`,
});

export const getZzalDetails = (imageId: number) =>
http.get<GetZzalDetailsResponse>({ url: `/v1/image/${imageId}` });

export const postImageLike = (imageId: number) =>
http.post<GetZzalResponse>({
url: `/v1/image/${imageId}/like`,
Expand Down
14 changes: 12 additions & 2 deletions src/components/ImageDetailModal/ButtonWithIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@ import { ButtonHTMLAttributes } from "react";
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
import { cn } from "@/utils/tailwind";
import Spinner from "../common/Spinner";

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
Icon: LucideIcon;
iconLabel: string;
children: ReactNode;
onClick: () => void;
isDisabled?: boolean;
isLoading?: boolean;
}

const ButtonWithIcon = ({ Icon, iconLabel, children, onClick, isDisabled = false }: Props) => {
const ButtonWithIcon = ({
Icon,
iconLabel,
children,
onClick,
isDisabled = false,
isLoading = false,
}: Props) => {
return (
<button
onClick={onClick}
Expand All @@ -21,7 +30,8 @@ const ButtonWithIcon = ({ Icon, iconLabel, children, onClick, isDisabled = false
"cursor-pointer": !isDisabled,
})}
>
<Icon aria-label={iconLabel} />
{!isLoading && <Icon aria-label={iconLabel} />}
{isLoading && <Spinner />}
<span className="mt-1 hidden text-xs sm:flex">{children}</span>
</button>
);
Expand Down
12 changes: 5 additions & 7 deletions src/components/ImageDetailModal/TagSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { Navigation } from "swiper/modules";
import SwiperCore from "swiper";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/utils/tailwind";
import { TagDetail } from "@/types/tag";
import { Tag } from "@/types/tag";
import TagBadge from "../common/TagBadge";

interface Props {
tags: TagDetail[];
tags: Tag[];
textSize?: string;
className?: string;
onClick?: () => void;
Expand Down Expand Up @@ -71,15 +71,13 @@ const TagSlider = ({ tags, textSize = "xs", className, onClick }: Props) => {
>
<ChevronLeft strokeWidth={1.5} />
</button>

{tags.map(({ name, id }) => (
<SwiperSlide key={id} className="w-fit cursor-pointer text-center text-text-primary">
{tags.map(({ tagName, tagId }) => (
<SwiperSlide key={tagId} className="w-fit cursor-pointer text-center text-text-primary">
<button onClick={onClick}>
<TagBadge content={name} className={`bg-primary px-2 py-1 text-${textSize}`} />
<TagBadge content={tagName} className={`bg-primary px-2 py-1 text-${textSize}`} />
</button>
</SwiperSlide>
))}

<button
className={cn(arrowButtonClasses, "right-0 bg-gradient-to-l pl-7pxr", {
hidden: !showNextButton,
Expand Down
133 changes: 66 additions & 67 deletions src/components/ImageDetailModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useState } from "react";
import { useState, Fragment, Suspense } from "react";
import { toast } from "react-toastify";
import { useOverlay } from "@toss/use-overlay";
import { Heart, Copy, FolderDown, SendHorizontal, Siren, Trash2, Hash } from "lucide-react";
import { useOverlay } from "@toss/use-overlay";
import { cn } from "@/utils/tailwind";
import { copyZzal, downloadZzal } from "@/utils/zzalUtils";
import { debounce } from "@/utils/debounce";
import ReportConfirmModal from "../ReportConfirmModal";
import ButtonWithIcon from "./ButtonWithIcon";
import TagSlider from "./TagSlider";
import Modal from "@/components/common/modals/Modal";
import useGetZzalDetails from "@/hooks/api/zzal/useGetZzalDetails";
import usePostReportZzal from "@/hooks/api/zzal/usePostReportZzal";
import useDeleteMyZzal from "@/hooks/api/zzal/useDeleteMyZzal";

Expand All @@ -15,62 +18,21 @@ interface Props {
onClose: () => void;
}

const imageDetails = {
imageId: 1,
uploadUserId: 123,
imgUrl:
"https://zzalmyu-bucket.s3.ap-northeast-2.amazonaws.com/upload/keroro9073%40gmail.comtemp_image1626418983561547350.jpg",
imageLikeYn: true,
tags: [
{
id: 0,
name: "일이삼사오육칠팔구십",
splitName: "o",
createdAt: "2024-03-01T05:59:48.528Z",
},
{
id: 1,
name: "일이삼사오육칠팔구십",
splitName: "o",
createdAt: "2024-03-01T05:59:48.528Z",
},
{
id: 2,
name: "일이삼사오육칠팔구십",
splitName: "o",
createdAt: "2024-03-01T05:59:48.528Z",
},
{ id: 3, name: "음식", splitName: "o", createdAt: "2024-03-01T05:59:48.528Z" },
{ id: 4, name: "여행", splitName: "o", createdAt: "2024-03-01T05:59:48.528Z" },
{ id: 5, name: "무한도전", splitName: "o", createdAt: "2024-03-01T05:59:48.528Z" },
{ id: 6, name: "운동", splitName: "o", createdAt: "2024-03-01T05:59:48.528Z" },
],
imageTitle: "안유진",
};
const IMAGEID = 70;
//TODO: [2024.03.06] 실제 IMAGEID 받기

const ImageDetailModal = ({ isOpen, onClose }: Props) => {
const ImageDetailModalContent = () => {
const [isTagNavigatorOpen, setIsTagNavigatorOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { zzalDetails } = useGetZzalDetails(IMAGEID);
const { reportZzal } = usePostReportZzal();
const { deleteMyZzal } = useDeleteMyZzal();
const [isTagNavigatorOpen, setIsTagNavigatorOpen] = useState(false);
const { imageId, imageLikeYn, imgUrl, tags, imageTitle, uploadUserId } = imageDetails;
const [isLiked, setIsLiked] = useState(imageLikeYn);
const reportConfirmOverlay = useOverlay();
const isUploader = uploadUserId === 123;
{
/*TODO: [2024.03.01] 추후 실제 사용자 아이디와 비교하기 */
}

const toggleTagNavigator = () => {
setIsTagNavigatorOpen(!isTagNavigatorOpen);
};

const handleClickLike = () => {
setIsLiked((prevLiked) => !prevLiked);
};
const { isLiked, imageUrl, tags, imageTitle, uploadUserId, imageId } = zzalDetails;

const handleDownloadZzal = () => {};

const handleSendToChat = () => {};
const isUploader = uploadUserId === 19;
//TODO: [2024.03.01] 추후 실제 사용자 아이디와 비교하기

const handleClickReportCompeleteButton = (imageId: number) => () => {
reportZzal(imageId, {
Expand All @@ -93,39 +55,65 @@ const ImageDetailModal = ({ isOpen, onClose }: Props) => {
));
};

const handleClickDeleteButton = () => {
const handleClickDeleteButton = debounce(() => {
setIsDeleting(true);

deleteMyZzal(imageId, {
onSuccess: () => {
toast.success("사진이 삭제되었습니다.");
},
onError: () => {
toast.error("사진 삭제에 실패했습니다.");
},
onSettled: () => {
setIsDeleting(false);
},
}); // TODO: [2024-03-05] 모달 클릭 시 URL이 변경되도록 구현 후, 이미지 삭제 성공 시 이전 페이지로 이동하는 navigate 추가 필요
};
}, 500);

const handleCopyZzal = async () => {};
const handleClickDownloadButton = debounce(async () => {
setIsDownloading(true);

{
/*TODO: [2024.03.05] 해당 handler함수 로직 추가하기*/
}
await downloadZzal({
imageUrl,
imageTitle,
});

setIsDownloading(false);
}, 500);

const handleClickCopyButton = debounce(() => {
copyZzal(imageUrl);
Copy link
Contributor

@HY219 HY219 Mar 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현진님 궁금증 하나 남겨봅니다 😀

debounce를 써서 더블클릭 등을 했을 때 여러 번 이벤트가 발생하는 것을 방지해줄 수 있군요! 배워갑니다 👍

상세 페이지에 있는 신고, 삭제 버튼에도 debounce를 걸어주는게 좋을까요? 아니면 불필요할까요?
삭제 시에도 debounce를 걸어주는 것이 좋다는 글을 하나 봐서 여쭤봅니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 신고하기는 모달이 바로 나와서 괜찮을 것 같은데 삭제시에는 debounce를 걸어주시면 좋을 것 같습니당👍

Copy link
Contributor Author

@hyeonjinan096 hyeonjinan096 Mar 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메인에서 pull해서 삭제 함수에 debounce 적용했습니다~!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HY219 삭제 시에 debounce를 걸어주는게 왜 좋은지 여쭤봐도 될까요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얼핏 봤던 글이였는데 제가 잘못 알았던 것 같습니다.
찾아보니까 아래와 같은 글도 있네요!

현진님 수정해주셨는데 번거롭게 해드렸네요ㅜ!
아직 언제 debounce를 사용해주는지 파악이 안된 것 같아요
image
https://thewavelet.tistory.com/65

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 그러면 삭제시 디바운스를 제거하고 요청 중에 click하지 못하도록 변경하면 될까요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 그렇게 해주실 수 있으면 저는 좋은 방법이라고 생각합니다! 😀

}, 500);

const handleClickLikeButton = () => {};

const handleClickSendButton = () => {};

//TODO: [2024.03.05] 해당 handler함수 로직 추가하기

const toggleTagNavigator = () => {
setIsTagNavigatorOpen(!isTagNavigatorOpen);
};

return (
<Modal isOpen={isOpen} onClose={onClose} size="sm">
<Fragment>
<div className="relative flex w-full justify-center">
<div className="z-30 flex h-90pxr w-full justify-center bg-background">
<div className=" flex flex-grow items-center justify-between space-x-4 bg-background px-50pxr py-10pxr">
<ButtonWithIcon
Icon={FolderDown}
iconLabel="다운로드"
children="다운로드"
onClick={handleDownloadZzal}
onClick={handleClickDownloadButton}
isLoading={isDownloading}
isDisabled={isDownloading}
/>
<ButtonWithIcon
Icon={SendHorizontal}
iconLabel="채팅에 전송하기"
children="채팅에 전송하기"
onClick={handleSendToChat}
onClick={handleClickSendButton}
/>
<button
onClick={toggleTagNavigator}
Expand Down Expand Up @@ -153,7 +141,8 @@ const ImageDetailModal = ({ isOpen, onClose }: Props) => {
Icon={Trash2}
iconLabel="삭제하기"
children="삭제하기"
isDisabled={!isUploader}
isDisabled={!isUploader || isDeleting}
isLoading={isDeleting}
onClick={handleClickDeleteButton}
/>
</div>
Expand All @@ -168,13 +157,13 @@ const ImageDetailModal = ({ isOpen, onClose }: Props) => {
</div>
</div>
<div className=" max-h-500pxr overflow-auto">
<img src={imgUrl} alt={imageTitle} className="w-full" />
<img src={imageUrl} alt={imageTitle} className="w-full" />
</div>
<div className="fixed bottom-0 right-0 flex flex-col space-y-4 p-25pxr hover:text-gray-300">
<button onClick={handleCopyZzal}>
<button onClick={handleClickCopyButton}>
<Copy color="white" size={30} aria-label="복사하기" />
</button>
<button onClick={handleClickLike}>
<button onClick={handleClickLikeButton}>
<Heart
color="white"
size={30}
Expand All @@ -183,7 +172,17 @@ const ImageDetailModal = ({ isOpen, onClose }: Props) => {
/>
</button>
</div>
</Modal>
</Fragment>
);
};

const ImageDetailModal = ({ isOpen, onClose }: Props) => {
return (
<Suspense fallback={"...pending"}>
<Modal isOpen={isOpen} onClose={onClose} size="sm">
<ImageDetailModalContent />
</Modal>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트를 쪼개 주신 이유가 있으신가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useSuspenseQuery를 사용하기 위해 컴포넌트 상위에서 Suspense로 감싸줘야했습니다.
따라서 ImageDetailModalContent를 따로 분리했습니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suspense 때문에 불필요한 컴포넌트 분리가 생겼는데, 이 부분이 좀 걸리네요ㅠ 저도 한 번 고민해보겠습니다

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이미지 상세 모달의 스켈레톤이나 로딩 화면을 보여줄건지, 보여준다면 어떻게 보여줄건지가 중요해질 거 같은데 현진님 생각하신 방향 혹시 있으신가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

처음엔 짤이 있는 페이지에서 로딩 화면 보여주다가 데이터가 다 오면 모달을 띄어주는 걸 생각했습니다.
지금 생각해 보니 스켈레톤을 넣는 것도 좋을 것 같네요!
스켈레톤을 넣는다면 상단 메뉴에 #버튼을 제외한 4개의 버튼에 각각 표현하고 아래 이미지 렌더링 되는 부분은 다 스켈레톤 처리하는 건 어떨까요? 이렇게 되면 useQuery로 구현해도 되는 걸까요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

따로 페이지가 아닌 모달이라 조금 까다롭긴 하네요ㅠ스켈레톤 구현할때 다시 논의해보면 좋을 것 같습니다!

</Suspense>
);
};

Expand Down
9 changes: 9 additions & 0 deletions src/components/common/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const Spinner = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spinner의 사이즈를 조절할 수 있게 daisyUI에서 제공하는 spinner 컴포넌트 사이즈 토큰을 props로 전달받으면 어떨까요?
또 확장성을 위해 className도 props로 받고 div에 주입하는 건 어떨까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

관련해서 재민님과 논의해봤는데 현재로는 스피너가 사용되는부분이 짤 상세모달 버튼밖에 없어서
우선 패스하고 추후에 필요할시 변경하기로 했습니다:0

return (
<div className="h-6 w-6">
<span className="loading loading-spinner loading-xs" />
</div>
);
};

export default Spinner;
2 changes: 1 addition & 1 deletion src/components/common/ZzalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { toast } from "react-toastify";
import { Heart, SendHorizontal, Copy } from "lucide-react";
import { useSetAtom } from "jotai";
import { cn } from "@/utils/tailwind";
import { copyZzal } from "@/utils/copyZzal";
import { copyZzal } from "@/utils/zzalUtils";
import { ZzalType } from "@/types/queryKey";
import { useAddImageLike } from "@/hooks/api/zzal/useAddImageLike";
import { $setMessagePreview } from "@/store/chat";
Expand Down
17 changes: 17 additions & 0 deletions src/hooks/api/zzal/useGetZzalDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { getZzalDetails } from "@/apis/zzal";

const useGetZzalDetails = (imageId: number) => {
const { data: zzalDetails, ...rest } = useSuspenseQuery({
queryKey: ["zzalDetails", imageId],
queryFn: () => getZzalDetails(imageId),
select: (data) => ({
isLiked: data.imageLikeYn,
imageUrl: data.imgUrl,
...data,
}),
});
return { zzalDetails, ...rest };
};

export default useGetZzalDetails;
7 changes: 0 additions & 7 deletions src/types/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,3 @@ export interface Tag {
tagName: string;
count: number;
}

export interface TagDetail {
id: number;
name: string;
splitName: string;
createdAt: string;
}
10 changes: 10 additions & 0 deletions src/types/zzal.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Tag } from "./tag";

export interface GetMyLikedZzalsResponse {
imageId: number;
path: string;
Expand All @@ -15,3 +17,11 @@ export interface GetZzalPagesResponse {
path: string;
imageLikeYn: boolean;
}
export interface GetZzalDetailsResponse {
imageId: number;
imageTitle: string;
uploadUserId: number;
imgUrl: string;
imageLikeYn: boolean;
tags: Tag[];
}
Loading
Loading