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

Issue164 리뷰 이미지 업로드 기능 추가 #165

Merged
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
001df53
fix: Star 컴포넌트 사이즈 버그 수정
hafnium1923 Jun 24, 2023
4a9790b
feat: 리뷰이미지 타입추가 및 mock data변경
hafnium1923 Jun 24, 2023
626d982
feat: 리뷰이미지 API 추가
hafnium1923 Jun 24, 2023
a2ae69c
feat: 이미지 업로드 컴포넌트 UI 구현
hafnium1923 Jun 24, 2023
bab0593
feat: 리뷰이미지 업로드 msw handler 구현
hafnium1923 Jun 24, 2023
7f128d4
feat: 리뷰이미지 업로드 훅 분리
hafnium1923 Jun 24, 2023
4f10130
feat: 리뷰이미지 컴포넌트 연결
hafnium1923 Jun 24, 2023
be7755b
feat: 리뷰이미지 리뷰아이템에 적용
hafnium1923 Jun 24, 2023
b3caf5c
fix: useImageUpload에서 불필요한 옵셔널 체이닝 및 변수 할당 제거
ashleysyheo Jun 25, 2023
66790c1
refactor: 리뷰 이미지에 border radius 추가
ashleysyheo Jun 26, 2023
3a4c4ae
refactor: 함수명 수정
ashleysyheo Jun 26, 2023
9c9058c
refactor: 이미지 업로드 한 후 ui 변경
ashleysyheo Jun 26, 2023
13b822d
fix: 상세페이지 갔을 때 무조건 리뷰 입력창 열리는 버그 수정
ashleysyheo Jun 27, 2023
00323ab
refactor: 이미지 삭제 버튼 ui 수정
ashleysyheo Jun 27, 2023
54b1c28
refactor: 이미지 업로드 실패 메세지 수정
ashleysyheo Jun 27, 2023
155b5a1
refactor: 불필요한 css property 제거
ashleysyheo Jun 28, 2023
27af139
refactor: imageHandler 임포트 상대경로에서 절대경로로 변경
ashleysyheo Jun 28, 2023
9613370
refactor: ImageUploadInput 컴포넌트에서 alt 텍스트를 props로 받도록 수정
ashleysyheo Jun 28, 2023
08fbe37
style: 비슷한 css property끼리 묶기
ashleysyheo Jun 28, 2023
77f41e5
refactor: ImageUploadInput 컴포넌트에서 input 태그 기본 attribute를 props로 사용할 수…
ashleysyheo Jun 29, 2023
a300b20
style: Textarea input max-width 추가
ashleysyheo Jun 29, 2023
d587de3
fix: 업로드한 이미지 삭제 후 같은 이미지 재업로드 안 되는 문제 해결
ashleysyheo Jul 1, 2023
0cca335
refactor: textarea resize 못하게 하기
ashleysyheo Jul 1, 2023
c97d818
refactor: 스타일링 분기 처리할 때 클래그 네임 사용 대신 함수를 만들어서 css 리턴하도록 수정
ashleysyheo Jul 1, 2023
9ca5c8e
Merge branch 'develop' into issue164-리뷰-이미지-업로드-기능-추가
ashleysyheo Jul 3, 2023
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions src/api/image/sendImageUploadPostRequest.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

디렉토리명 > image보다 더 구체적으로 정하면 어떨까요?
review 디렉토리에 넣지 않고 image를 새로 만든 이유가 있을까요?

Copy link
Contributor

Choose a reason for hiding this comment

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

이미지 api 자체를 재사용할 수 있게 만들기 위해서 리뷰를 보낼 때 이미지 파일 데이터를 보내서 imageUrl을 생성하는 것이 아닌 별도의 api를 만들었다고 백엔드한테 들었습니다. 그런 의미에서 추후에 이 api가 리뷰가 아닌 다른 상황에서 사용될 것을 생각하면 images를 만드는 것이 좀 더 어울리지 않나 싶어서 그렇게 했습니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

아하 그러면 한 api로 모든 종류의 이미지 업로드를 다루는건가요?? 아니면 나중에 /image/review 식으로 세부적으로 나뉘나요?

Copy link
Contributor

Choose a reason for hiding this comment

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

제가 이해하기로는 이 images api로 모든 종류의 이미지 업로드를 다 다룬다고 알고 있습니다! 그래서 백엔드 쪽에서도 리뷰 폼을 제출할 때 이미지 파일 데이터를 보내는 것이 아니라, 일차적으로 이미지 url을 요청한 다음에 그 url을 리뷰 보낼 때 함께 보내도록 만든 것 같아요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AxiosResponse } from "axios";

import { ACCESS_TOKEN, ENDPOINTS } from "constants/api";

import axiosInstance from "api/axiosInstance";

interface ImageUploadResponse {
imageUrl: string;
}

const sendImageUploadPostRequest = async (imageFile: File) => {
const accessToken = window.sessionStorage.getItem(ACCESS_TOKEN);

if (!accessToken) {
window.sessionStorage.removeItem(ACCESS_TOKEN);
window.alert("다시 로그인 해주세요");
window.location.reload();
throw new Error("엑세스토큰이 유효하지 않습니다");
}
ashleysyheo marked this conversation as resolved.
Show resolved Hide resolved

const response: AxiosResponse<ImageUploadResponse> = await axiosInstance.post(
ashleysyheo marked this conversation as resolved.
Show resolved Hide resolved
ENDPOINTS.IMAGE_UPLOAD,
imageFile,
{
headers: {
Authorization: `Bearer ${accessToken}`,
ashleysyheo marked this conversation as resolved.
Show resolved Hide resolved
"Content-Type": "multipart/form-data",
},
}
);

return response.data;
};

export default sendImageUploadPostRequest;
3 changes: 3 additions & 0 deletions src/asset/image-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/asset/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { ReactComponent as CloseIcon } from "./close-icon.svg";
export { ReactComponent as LogoLight } from "./logo-light.svg";
export { ReactComponent as PlusIcon } from "./plus-icon.svg";
export { ReactComponent as SearchIcon } from "./search-icon.svg";
export { ReactComponent as ImageIcon } from "./image-icon.svg";
67 changes: 67 additions & 0 deletions src/components/common/ImageUploadInput/ImageUploadInput.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import styled, { css } from "styled-components";

export const Container = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacer.spacing2};
`;

export const InputWrapper = styled.div`
display: flex;
align-items: flex-start;
gap: ${({ theme }) => theme.spacer.spacing2};
`;

export const Input = styled.input`
display: none;
`;

export const UploadedImageWrapper = styled.div`
position: relative;
height: 10rem;
`;

export const UploadedImage = styled.img`
width: 10rem;
height: 10rem;
object-fit: cover;
border-radius: ${({ theme }) => theme.borderRadius.small};
`;

export const DeleteButton = styled.button`
position: absolute;
right: 0;
width: 3.6rem;
height: 3.6rem;
background-color: ${({ theme }) => theme.color.black};

border: none;
border-radius: 0;
border-top-right-radius: ${({ theme }) => theme.borderRadius.small};
outline: 0;

transition: all 0.2s ease-in;

&:hover {
background-color: ${({ theme }) => theme.color.gray800};
}

& svg {
width: 12px;
height: 12px;

& > path {
stroke: ${({ theme }) => theme.color.white};
}
}
`;

export const getUploadButtonStyle = (isUploaded: boolean) => css`
height: 10rem;
display: ${isUploaded ? "none" : "flex"};
align-items: center;
justify-content: center;
gap: ${({ theme }) => theme.spacer.spacing2};
font-weight: normal;
`;
66 changes: 66 additions & 0 deletions src/components/common/ImageUploadInput/ImageUploadInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Button } from "../Button/Button.style";
import { Label } from "../Label/Label.style";
ashleysyheo marked this conversation as resolved.
Show resolved Hide resolved
import * as S from "./ImageUploadInput.style";
import { ComponentPropsWithoutRef, useRef } from "react";

import { CloseIcon, ImageIcon } from "asset";

interface ImageUploadInputProps extends ComponentPropsWithoutRef<"input"> {
label?: string;
imageUrl: string | null;
ashleysyheo marked this conversation as resolved.
Show resolved Hide resolved
imageAltText: string;
onRemove: () => void;
}
function ImageUploadInput({
id,
label,
imageUrl,
imageAltText,
onRemove,
...attributes
}: ImageUploadInputProps) {
const inputRef = useRef<HTMLInputElement | null>(null);

const handleUploadButton = () => {
if (!inputRef.current) return;

inputRef.current.click();
};

return (
<S.Container>
{label && <Label id={id}>{label}</Label>}
<S.InputWrapper>
<Button
css={S.getUploadButtonStyle(!!imageUrl)}
type="button"
className={imageUrl ? "uploaded" : ""}
onClick={handleUploadButton}
>
<ImageIcon />
{!imageUrl && " 이미지를 업로드해 주세요"}
</Button>
<S.Input
type="file"
accept="image/*"
id={id}
ref={inputRef}
{...attributes}
/>
{imageUrl && (
<S.UploadedImageWrapper>
<S.UploadedImage src={imageUrl} alt={imageAltText} />
<S.DeleteButton
type="button"
aria-label="이미지 삭제"
onClick={onRemove}
>
<CloseIcon />
</S.DeleteButton>
</S.UploadedImageWrapper>
)}
</S.InputWrapper>
</S.Container>
);
}
export default ImageUploadInput;
16 changes: 8 additions & 8 deletions src/components/common/Star/Star.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import styled, { css } from "styled-components";
const getSizeStyling = (size: Required<StarProps>["size"]) => {
const style = {
lg: css`
width: "32px";
height: "32px";
width: 32px;
height: 32px;
`,
md: css`
width: "24px";
height: "24px";
width: 24px;
height: 24px;
`,
sm: css`
width: "20px";
height: "20px";
width: 20px;
height: 20px;
`,
xs: css`
width: "16px";
height: "16px";
width: 16px;
height: 16px;
uk960214 marked this conversation as resolved.
Show resolved Hide resolved
`,
};

Expand Down
1 change: 1 addition & 0 deletions src/components/common/Textarea/Textarea.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const Textarea = styled.textarea<TextareaProps>`
color: ${({ theme }) => theme.color.gray800};
border: 1px solid ${({ theme }) => theme.color.gray100};
border-radius: ${({ theme }) => theme.borderRadius.small};
resize: none;

${({ isError }) =>
isError &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { NETWORK } from "constants/api";
import { MESSAGES } from "constants/messages";
import { INPUT_MAX_LENGTH } from "constants/rules";

import { useImageUpload } from "hooks/useImageUpload";
import useLogin from "hooks/useLogin";

import sendReviewPostRequest from "api/review/sendReviewPostRequest";

import BottomSheet from "components/common/BottomSheet/BottomSheet";
import Button from "components/common/Button/Button";
import ImageUploadInput from "components/common/ImageUploadInput/ImageUploadInput";
import Input from "components/common/Input/Input";
import Label from "components/common/Label/Label";
import StarRating from "components/common/StarRating/StarRating";
Expand All @@ -36,6 +38,8 @@ function ReviewInputBottomSheet({
const [rating, setRating] = useState(DEFAULT_RATING);
const [reviewContent, setReviewContent] = useState("");
const [menuInput, setMenuInput] = useState("");
const { uploadedImageUrl, handleImageUpload, handleImageRemoval } =
useImageUpload();

const { logout } = useLogin();

Expand All @@ -45,6 +49,7 @@ function ReviewInputBottomSheet({
content: reviewContent,
rating: rating + 1,
menu: menuInput,
imageUrl: uploadedImageUrl,
});
closeSheet();
};
Expand Down Expand Up @@ -121,6 +126,14 @@ function ReviewInputBottomSheet({
maxLength={255}
required
/>
<ImageUploadInput
id="image-upload"
label="이미지 업로드"
imageUrl={uploadedImageUrl}
imageAltText="리뷰 이미지"
onChange={handleImageUpload}
onRemove={handleImageRemoval}
/>
<Button variant="primary">제출</Button>
</S.Form>
</BottomSheet>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { NETWORK } from "constants/api";
import { MESSAGES } from "constants/messages";
import { INPUT_MAX_LENGTH } from "constants/rules";

import { useImageUpload } from "hooks/useImageUpload";
import useLogin from "hooks/useLogin";

import sendReviewItem from "api/review/sendReviewItem";

import BottomSheet from "components/common/BottomSheet/BottomSheet";
import Button from "components/common/Button/Button";
import ImageUploadInput from "components/common/ImageUploadInput/ImageUploadInput";
import Input from "components/common/Input/Input";
import Label from "components/common/Label/Label";
import StarRating from "components/common/StarRating/StarRating";
Expand All @@ -28,6 +30,7 @@ interface ReviewUpdateBottomSheetProps {
menu: string;
restaurantId: string;
id: string;
imageUrl: string | null;
};
onSuccess: () => void;
}
Expand All @@ -42,6 +45,8 @@ function ReviewUpdateBottomSheet({
defaultReviewItem.content
);
const [menu, setMenu] = useState<string>(defaultReviewItem.menu);
const { uploadedImageUrl, handleImageUpload, handleImageRemoval } =
useImageUpload(defaultReviewItem.imageUrl);

const { logout } = useLogin();

Expand All @@ -51,6 +56,7 @@ function ReviewUpdateBottomSheet({
content: reviewContent,
rating: rating + 1,
menu: menu,
imageUrl: uploadedImageUrl,
});
closeSheet();
};
Expand Down Expand Up @@ -133,6 +139,14 @@ function ReviewUpdateBottomSheet({
maxLength={255}
required
/>
<ImageUploadInput
id="image-upload"
label="이미지 업로드"
imageUrl={uploadedImageUrl}
imageAltText="리뷰 이미지"
onChange={handleImageUpload}
onRemove={handleImageRemoval}
/>
<Button variant="primary">제출</Button>
</S.Form>
</BottomSheet>
Expand Down
11 changes: 10 additions & 1 deletion src/components/pages/StoreDetailPage/StoreDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,15 @@ function StoreDetailPage() {
)}
{reviews.length ? (
reviews.map(
({ id, author, rating, content, menu, updatable }) => (
({
id,
author,
rating,
content,
menu,
imageUrl,
updatable,
}) => (
<Fragment key={id}>
<StoreReviewItem
reviewInfo={{
Expand All @@ -104,6 +112,7 @@ function StoreDetailPage() {
rating,
content,
menu,
imageUrl,
updatable,
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const UserReviewInfoWrapper = styled.div`
export const ReviewBottom = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: ${({ theme }) => theme.spacer.spacing3};
`;

Expand Down Expand Up @@ -75,6 +76,11 @@ export const DropBoxButton = styled.button`
}
`;

export const ReviewImage = styled.img`
height: 10rem;
border-radius: ${({ theme }) => theme.borderRadius.small};
`;

export const titleTextStyle = css`
font-weight: 600;
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function StoreReviewItem({ reviewInfo }: { reviewInfo: ReviewInfo }) {

const queryClient = useQueryClient();

const { author, rating, content, menu } = reviewInfo;
const { author, rating, content, menu, imageUrl } = reviewInfo;

const handleMeatballButtonClick = () => setIsDropBoxOpen((prev) => !prev);
const handleDropBoxClose = () => setIsDropBoxOpen(false);
Expand Down Expand Up @@ -123,6 +123,7 @@ function StoreReviewItem({ reviewInfo }: { reviewInfo: ReviewInfo }) {
<Text css={S.bodyTextStyle} size="sm">
{content}
</Text>
{imageUrl && <S.ReviewImage src={imageUrl} alt="리뷰 이미지" />}
<Text css={S.menuTextStyle} size="sm">
{menu}
</Text>
Expand Down
Loading