-
Notifications
You must be signed in to change notification settings - Fork 1
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
[#481] [책 상세] 책 정보 컴포넌트 #482
Changes from all commits
c9bdad6
a9c834e
49f0d8b
bf2a671
d37c13e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { Meta, StoryObj } from '@storybook/react'; | ||
import BookInfo from '@/v1/book/detail/BookInfo'; | ||
|
||
const meta: Meta<typeof BookInfo> = { | ||
title: 'book/detail/BookInfo', | ||
component: BookInfo, | ||
}; | ||
|
||
export default meta; | ||
|
||
type Story = StoryObj<typeof BookInfo>; | ||
|
||
export const Default: Story = { | ||
args: { bookId: 22 }, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,14 @@ | ||
import { useState } from 'react'; | ||
'use client'; | ||
|
||
import { Children, ReactNode, useState } from 'react'; | ||
import Image from 'next/image'; | ||
|
||
type AvatarSize = 'small' | 'medium' | 'large'; | ||
interface AvatarProps { | ||
name?: string; | ||
src?: string; | ||
size?: AvatarSize; | ||
border?: boolean; | ||
} | ||
|
||
const FALLBACK_IMAGE_SRC = '/icons/avatar.svg'; | ||
|
@@ -33,15 +36,17 @@ const getAvatarSize = (size: AvatarSize) => { | |
} | ||
}; | ||
|
||
const Avatar = ({ name, src, size = 'medium' }: AvatarProps) => { | ||
const Avatar = ({ name, src, size = 'medium', border }: AvatarProps) => { | ||
const [image, setImage] = useState(src ?? FALLBACK_IMAGE_SRC); | ||
|
||
const { sizeClasses, sizeProps } = getAvatarSize(size); | ||
Comment on lines
+39
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ask; 해당 PR의 작업내용은 아니지만
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 맞아요! src에 값이 있더라도 유효한 url이 아닌 경우 깨진 이미지가 보여서, 이를 해결하려면 상태를 사용할 수 밖에 없었어요. |
||
const borderClass = border ? 'border-[0.15rem]' : 'border-none'; | ||
|
||
const setFallbackImage = () => setImage(FALLBACK_IMAGE_SRC); | ||
|
||
return ( | ||
<span | ||
className={`relative inline-block rounded-full bg-white ${sizeClasses}`} | ||
className={`relative inline-block rounded-full border-white bg-white ${sizeClasses} ${borderClass}`} | ||
> | ||
<Image | ||
alt={name || 'avatar'} | ||
|
@@ -55,3 +60,17 @@ const Avatar = ({ name, src, size = 'medium' }: AvatarProps) => { | |
}; | ||
|
||
export default Avatar; | ||
|
||
const AvatarGroup = ({ children }: { children?: ReactNode }) => { | ||
return ( | ||
<div className="mr-[0.75rem] flex flex-row-reverse items-center justify-end"> | ||
{Children.toArray(children).map((avatar, idx) => ( | ||
<span key={idx} className={`-me-[0.75rem] leading-none`}> | ||
{avatar} | ||
</span> | ||
))} | ||
</div> | ||
); | ||
}; | ||
|
||
export { AvatarGroup }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { APIBook } from '@/types/book'; | ||
import { IconBookmark } from '@public/icons'; | ||
import { useBookInfo } from '@/queries/book/useBookInfoQuery'; | ||
|
||
import Avatar, { AvatarGroup } from '@/v1/base/Avatar'; | ||
import BookCover from '@/v1/book/BookCover'; | ||
import useBookUserInfoQuery from '@/queries/book/useBookUserInfoQuery'; | ||
|
||
const BookInfo = ({ bookId }: { bookId: APIBook['bookId'] }) => { | ||
const { data } = useBookInfo(bookId); | ||
const { title, author, imageUrl, summary, bookUrl } = data; | ||
|
||
return ( | ||
<div className="flex flex-col gap-[2rem] rounded-l-[1.5rem] bg-white p-[2rem] shadow-bookcard"> | ||
<div className="flex items-end gap-[2rem]"> | ||
<BookCover size="2xlarge" src={imageUrl} /> | ||
|
||
<div className="flex flex-col gap-[0.5rem]"> | ||
<BookmarkButton /> | ||
<BookTitle title={title} /> | ||
<BookAuthor author={author} /> | ||
</div> | ||
</div> | ||
|
||
<BookSummary summary={summary} bookUrl={bookUrl} /> | ||
<BookmarkUserInfo bookId={bookId} /> | ||
</div> | ||
); | ||
Comment on lines
+13
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comment; BookCover 사이즈 수정에 따라 달라진 디자인을 Figma에 반영해 두었습니다 |
||
}; | ||
|
||
export default BookInfo; | ||
|
||
const BookTitle = ({ title }: { title: string }) => ( | ||
<p className="text-lg font-bold">{title}</p> | ||
); | ||
|
||
const BookAuthor = ({ author }: { author: string }) => ( | ||
<p className="text-sm">{author}</p> | ||
); | ||
|
||
const BookmarkButton = () => { | ||
return ( | ||
<IconBookmark | ||
className="mb-[0.5rem] h-[2.4rem] w-[2.4rem] cursor-pointer stroke-main-900 stroke-[0.15rem]" | ||
fill="white" | ||
/> | ||
); | ||
}; | ||
|
||
const BookSummary = ({ | ||
summary, | ||
bookUrl, | ||
}: { | ||
summary: string; | ||
bookUrl: string; | ||
}) => ( | ||
<p className="text-md"> | ||
{summary} ... | ||
{bookUrl && ( | ||
<a target="_blank" href={bookUrl}> | ||
<span className="cursor-pointer text-main-900">더보기</span> | ||
</a> | ||
)} | ||
</p> | ||
); | ||
|
||
const BookmarkUserInfo = ({ bookId }: { bookId: APIBook['bookId'] }) => { | ||
const { data } = useBookUserInfoQuery(bookId); | ||
const { totalCount, users } = data; | ||
const avatarCount = users.length; | ||
|
||
return ( | ||
<div className="flex items-center gap-[0.5rem]"> | ||
{avatarCount !== 0 && ( | ||
<AvatarGroup> | ||
{users.map(({ userId, profileImage }) => ( | ||
<a key={userId} href={`/profile/${userId}`}> | ||
<Avatar src={profileImage} border /> | ||
</a> | ||
))} | ||
</AvatarGroup> | ||
)} | ||
<p className="text-sm"> | ||
{getBookmarkedUserCountText(totalCount, avatarCount)} | ||
</p> | ||
</div> | ||
); | ||
}; | ||
|
||
const getBookmarkedUserCountText = ( | ||
totalCount: number, | ||
avatarCount: number | ||
) => { | ||
const otherCount = totalCount - avatarCount; | ||
|
||
if (otherCount === 0 && totalCount === 0) { | ||
return '아직 이 책을 책장에 꽂은 사람이 없어요.'; | ||
} else if (otherCount === 0) { | ||
return '님이 이 책을 책장에 꽂았어요.'; | ||
} | ||
|
||
return `외 ${otherCount}명이 이 책을 책장에 꽂았어요.`; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question;
텐스택 쿼리 고수가 되신것같네요
순수하게 모르겠어서.. 질문드립니다...!
해당 transformBookData으로 어떤 작업을 할 수 있는지 궁금합니다..! 🙇
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hanyugeon
책 상세 API 호출 후, 공통적으로 진행해야하는 데이터 후처리 작업을 정의해뒀어요. API 응답 스키마에서 클라이언트에 필요한 데이터들을 추출하여 알맞은 prop 이름으로 재정의하고, 고해상도의 책 이미지를 요청하기 위해
imageUrl
을 수정하는 작업을 하고 있어요!query의 select 옵션을 사용하면, query 단에서 응답 데이터 구조를 변형할 수 있어요. 개인적으로 컴포넌트 내부에서 데이터 구조를 변형하게 되면 UI 코드와 혼재되어 컴포넌트의 복잡도가 커진다고 느꼈어요. 그리고 동일한 데이터 변형 혹은 후처리 과정이 query 호출 시 마다 반복되는 부분도 개선하고 싶었어요!
몇달 전(?) select를 처음 도입했었던 pr에서도 언급했었는데, 이 아티클을 참고했어요!