Skip to content

Commit

Permalink
[#46] Feat: 짤 업로드 api, 추천 태그 api 연결 및 Toast 구현 (#68)
Browse files Browse the repository at this point in the history
Feat: 짤 업로드 api 통합 구현

Feat: 전체 유저 인기 태그 api 통합 구현

Feat: 짤 업로드 완료 시 초기화 구현

Feat: react-tostify를 활용하여 상황별 Toast 구현 (사진파일, 제목, 태그 미등록 & HTTP 요청 실패 & 업로드 성공)

Style: toast.success color를 primary color로 지정

Refactor: handleUploadZzal 함수에서 mutation 객체 직접 사용하여 성공/실패 콜백 처리

Refactor: RecommendTag 컴포넌트의 recommendTags props에 popularTagsName이 아닌 ppopularTags 전달

Refactor: if-else 구문을 early return 문으로 변경

Refactor: 파일이 없을 경우 바로 return

Refactor: previewUrl을 atom이 아닌 useState 상태로 두고 props로 넘겨줘서 사용

Feat: 짤 제목 input 창 추가 및 등록

Style: 기본 및 반응형 UI 수정

Refactor: 불필요한 falsy값 체크 코드 제거

Refactor: 업로드할 미리보기 이미지 img태그를 사용하여 보여주도록 변경 및 style 변경

Refactor: 커스텀 api 훅의 요청 함수를 화살표 함수로 작성하는 규칙에 맞게 형식 변경
  • Loading branch information
HY219 authored Mar 13, 2024
1 parent d44f53c commit dd1190c
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 53 deletions.
8 changes: 7 additions & 1 deletion src/apis/tag.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import http from "@/apis/core";
import {
GetPopularTagsResponse,
GetTagsResponse,
GetTopTagsFromUploadedResponse,
GetTopTagsFromLikedResponse,
PostTagResponse,
} from "@/types/tag.dto";
import http from "./core";

export const getPopularTags = () =>
http.get<GetPopularTagsResponse>({
url: `/v1/tag/popular`,
});

export const getSearchTag = (tag: string) =>
http.get<GetTagsResponse>({
Expand Down
23 changes: 21 additions & 2 deletions src/apis/zzal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { GetMyLikedZzalsResponse, GetZzalDetailsResponse, GetZzalResponse } from "@/types/zzal.dto";
import {
PostUploadZzalRequest,
GetMyLikedZzalsResponse,
GetZzalDetailsResponse,
GetZzalResponse,
} from "@/types/zzal.dto";
import http from "./core";

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

export const postUploadZzal = ({ file, tagIdList, title }: PostUploadZzalRequest) => {
const formData = new FormData();
formData.append("file", file);
formData.append("tagIdList", tagIdList.join(","));
formData.append("title", title);

return http.post<PostUploadZzalRequest>({
url: `/v1/image`,
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
};

export const deleteMyZzal = (imageId: number) => {
return http.delete<number>({ url: `/v1/image/${imageId}` });
};
Expand Down
69 changes: 38 additions & 31 deletions src/components/UploadZzal/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import { Fragment, useRef, useState, DragEvent, ChangeEvent } from "react";
import { Upload } from "lucide-react";
import { cn } from "@/utils/tailwind";
import ZzalCard from "@/components/common/ZzalCard";

interface Props {
onChange: (file: File | null) => void;
changeFile: (file: File | null) => void;
file: File | null;
}

const ImageUpload = ({ onChange }: Props) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const ImageUpload = ({ changeFile, file }: Props) => {
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const handleClickDeleteButton = () => {
onChange(null);
setPreviewUrl(null);
changeFile(null);
};

const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (files) {
const [changedFile] = files;
if (changedFile) {
onChange(changedFile);
setPreviewUrl(URL.createObjectURL(changedFile));
}

if (!files) return;

const [changedFile] = files;

if (changedFile) {
changeFile(changedFile);
}
};

Expand All @@ -37,6 +37,7 @@ const ImageUpload = ({ onChange }: Props) => {
event.preventDefault();

const { items } = event.dataTransfer;

if (items?.length > 0) {
setDragging(true);
}
Expand All @@ -56,40 +57,47 @@ const ImageUpload = ({ onChange }: Props) => {
event.preventDefault();
event.stopPropagation();

const { dataTransfer } = event;
if (dataTransfer) {
const [changedFile] = dataTransfer.files;
if (changedFile) {
onChange(changedFile);
setPreviewUrl(URL.createObjectURL(changedFile));
}
const { files } = event.dataTransfer;

const [changedFile] = files;

if (changedFile) {
changeFile(changedFile);
}

setDragging(false);
};

return (
<div
className={cn(
!previewUrl && "cursor-pointer",
!file && "cursor-pointer",
dragging ? "border-primary text-primary" : "border-text-secondary text-text-secondary",
"relative flex h-320pxr w-320pxr flex-col items-center justify-center gap-50pxr overflow-clip rounded-8pxr border-4 border-dashed text-2xl font-bold transition-colors hover:text-primary sm:h-400pxr",
"relative flex min-h-320pxr w-320pxr flex-col items-center justify-center gap-50pxr overflow-clip rounded-8pxr border-4 border-dashed text-2xl font-bold transition-colors hover:text-primary sm:min-h-400pxr",
)}
onClick={handleChooseFile}
onDrop={handleFileDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
>
{previewUrl && (
<button
className="absolute right-7pxr top-7pxr z-10 h-30pxr w-30pxr rounded-full bg-neutral pb-1pxr pl-1pxr text-base text-text-primary outline outline-transparent transition-[outline] hover:outline-delete"
onClick={handleClickDeleteButton}
aria-label="사진 제거하기"
>
</button>
{file && (
<Fragment>
<img
src={file ? URL.createObjectURL(file) : ""}
alt="업로드 사진"
className="w-320pxr rounded-lg object-contain"
/>
<button
className="absolute right-7pxr top-7pxr z-10 h-30pxr w-30pxr rounded-full bg-neutral pb-1pxr pl-1pxr text-base text-text-primary outline outline-transparent transition-[outline] hover:outline-delete"
onClick={handleClickDeleteButton}
aria-label="사진 제거하기"
>
</button>
</Fragment>
)}
{!previewUrl && (
{!file && (
<Fragment>
<Upload aria-label="업로드하기" size={72} />
<div className={`hidden text-center sm:block`}>
Expand All @@ -105,7 +113,6 @@ const ImageUpload = ({ onChange }: Props) => {
/>
</Fragment>
)}
{previewUrl && <ZzalCard src={previewUrl} alt="업로드 사진" width={320} />}
</div>
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/components/UploadZzal/UploadGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Info } from "lucide-react";

const UploadGuide = () => {
return (
<div className="flex min-h-70pxr w-full flex-col items-center justify-center gap-10pxr rounded-[16px] bg-card p-10pxr text-base font-bold text-text-primary sm:flex-row sm:gap-30pxr">
<div className="flex min-h-70pxr w-full flex-col items-center justify-center gap-10pxr self-start rounded-[16px] bg-card p-10pxr text-base font-bold text-text-primary sm:min-w-780pxr sm:flex-row sm:gap-30pxr">
<Info strokeWidth={2} aria-label="참고" />
<span className="text-center">
업로드를 위해 필수로 사진을 선택하고 1개 이상의 태그를 등록해주세요!
Expand Down
14 changes: 8 additions & 6 deletions src/components/common/RecommendTag.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { cn } from "@/utils/tailwind";
import { Tag } from "@/types/tag";
import TagBadge from "./TagBadge";

interface Props {
title?: string;
recommendTags: Tag[];
className?: string;
}

const RecommendTag = ({ className }: Props) => {
const RecommendTag = ({ title, recommendTags, className }: Props) => {
// TODO: [2024.02.14] api 요청 예정
const recommendTags = ["분노", "스트레스", "박명수", "직장인", "잠좀자자"];

return (
<div className={cn("mb-4", className)}>
<div className="mb-4 text-sm font-bold">내가 가장 많이 사용한 태그</div>
<div className="mb-4 min-w-full text-sm font-bold">{title}</div>
<ul className="ml-2 flex w-650pxr flex-wrap gap-2 after:block after:w-650pxr after:border-b after:border-black after:content-['']">
{recommendTags.map((recommendTag, index) => (
<li key={`${index}-${recommendTag}`} className="mb-2">
<TagBadge content={recommendTag} isClickable />
{recommendTags.map(({ tagId, tagName }) => (
<li key={`${tagId}`} className="mb-2">
<TagBadge content={tagName} isClickable />
</li>
))}
</ul>
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/ZzalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const ZzalCard = ({
hasAnimation ? "transition duration-300 ease-in-out hover:brightness-75" : "none",
)}
>
<img src={src} alt={alt} className="h-full w-full rounded-lg object-cover" />
{src && <img src={src} alt={alt} className="h-full w-full rounded-lg object-cover" />}
</figure>
</div>
</ZzalCardContext.Provider>
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/api/tag/useGetPopularTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { getPopularTags } from "@/apis/tag";

const useGetPopularTags = () => {
const { data, ...rest } = useQuery({
queryKey: ["popularTags"],
queryFn: () => getPopularTags(),
});

return {
popularTags: data || [],
...rest,
};
};

export default useGetPopularTags;
18 changes: 18 additions & 0 deletions src/hooks/api/zzal/usePostUploadZzal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useMutation } from "@tanstack/react-query";
import { postUploadZzal } from "@/apis/zzal";

interface Props {
file: File;
tagIdList: Array<number>;
title: string;
}

const usePostUploadZzal = () => {
const { mutate, ...rest } = useMutation({
mutationFn: ({ file, tagIdList, title }: Props) => postUploadZzal({ file, tagIdList, title }),
});

return { uploadZzal: mutate, ...rest };
};

export default usePostUploadZzal;
4 changes: 4 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@
clip: rect(0 0 0 0);
clip-path: polygon(0 0, 0 0, 0 0);
}

:root {
--toastify-color-success: #246fff;
}
106 changes: 95 additions & 11 deletions src/routes/upload-zzal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,105 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ChangeEvent, useState } from "react";
import { toast } from "react-toastify";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useAtom } from "jotai";
import UploadGuide from "@/components/UploadZzal/UploadGuide";
import ImageUpload from "@/components/UploadZzal/ImageUpload";
import RecommendTag from "@/components/common/RecommendTag";
import TagSearchForm from "@/components/common/SearchTag/TagSearchForm";
import usePostUploadZzal from "@/hooks/api/zzal/usePostUploadZzal";
import useGetPopularTags from "@/hooks/api/tag/useGetPopularTags";
import { $selectedTags } from "@/store/tag";

const UploadZzal = () => {
const [, setFile] = useState<File | null>(null);
const handleChangeUpload = (changedFile: File | null) => setFile(changedFile);
const { popularTags } = useGetPopularTags();
const { uploadZzal } = usePostUploadZzal();
const [file, setFile] = useState<File | null>(null);
const [imageTitle, setImageTitle] = useState<string>("");
const [selectedTags, setSelectedTags] = useAtom($selectedTags);

const changeFile = (file: File | null) => {
setFile(file);
};

const handleClickUploadButton = () => {
if (!file) {
toast.error("사진을 등록해주세요!");
return;
}

if (!imageTitle) {
toast.error("제목을 입력해주세요!");
return;
}

if (!selectedTags.length) {
toast.error("1개 이상의 태그를 등록해주세요!");
return;
}

handleUploadZzal(file);
};

const handleUploadZzal = (file: File) => {
uploadZzal(
{
file: file,
// TODO: [2024.02.27] 선택한 태그의 Id를 전달하는 코드 구현 후, 실제 selectedTags Id 넘겨주기
tagIdList: [2, 3, 4],
title: imageTitle,
},
{
onSuccess: () => {
toast.success(
<div>
<span>성공적으로 업로드가 되었습니다.</span>
<Link to="/my-uploaded-zzals">
<button className="m-1 rounded bg-primary p-1 text-sm text-white">확인하기</button>
</Link>
</div>,
),
changeFile(null),
setImageTitle(""),
setSelectedTags([]);
},
onError: () => {
toast.error("사진 업로드에 실패했습니다.");
},
},
);
};

const handleChangeImageTitle = (event: ChangeEvent<HTMLInputElement>) => {
setImageTitle(event.target.value);
};

return (
<div className="flex h-full flex-col items-center gap-20pxr px-50pxr sm:px-100pxr">
<div className="mt-20pxr flex w-full flex-col items-center justify-center gap-50pxr sm:flex-row sm:items-start sm:gap-200pxr">
<ImageUpload onChange={handleChangeUpload} />
<div className="flex h-400pxr w-full min-w-400pxr max-w-500pxr flex-1 flex-col items-center justify-between">
<TagSearchForm className="" />
<div className="flex flex-col items-center gap-20pxr px-50pxr pt-30pxr sm:px-100pxr">
<div className="self-start text-2xl font-extrabold text-text-primary">짤 업로드</div>
<UploadGuide />
<div className="mt-20pxr flex w-full flex-col sm:flex-row sm:items-start">
<div className="mx-auto flex w-320pxr flex-col">
<ImageUpload changeFile={changeFile} file={file} />
</div>
<div className="mx-auto flex h-400pxr w-320pxr flex-1 flex-col pl-0 sm:w-450pxr sm:pl-10">
<span className="mb-4 pt-10 text-sm font-bold sm:pt-0">짤 제목</span>
<div className="mb-10 flex max-w-650pxr flex-wrap rounded-full border border-gray-300 py-1 pl-4 pr-2 shadow-xl">
<input
id="imageTitleInput"
name="imageTitle"
onChange={handleChangeImageTitle}
value={imageTitle}
className="z-20 min-h-12 flex-1 rounded-xl border-none bg-transparent outline-none"
/>
</div>
<RecommendTag
title="전체 사용자들이 가장 많이 사용한 태그 TOP 5"
recommendTags={popularTags}
/>
<TagSearchForm />
<button
className="h-60pxr w-full rounded-full bg-gradient-to-r from-primary to-[#78C6FF] text-lg font-bold text-white"
onClick={() => {}}
className="mt-10 h-60pxr w-full rounded-full bg-gradient-to-r from-primary to-[#78C6FF] text-lg font-bold text-white sm:max-w-650pxr"
onClick={handleClickUploadButton}
>
업로드하기
</button>
Expand Down
2 changes: 2 additions & 0 deletions src/types/tag.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Tag } from "./tag";

export type GetTagsResponse = Tag[];

export type GetPopularTagsResponse = Tag[];

export type GetTopTagsFromUploadedResponse = Tag[];

export type GetTopTagsFromLikedResponse = Tag[];
Expand Down
Loading

0 comments on commit dd1190c

Please sign in to comment.