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

[#481] [책 상세] 책 정보 컴포넌트 #482

Merged
merged 5 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 public/icons/bookmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/queries/book/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const bookKeys = {
details: () => [...bookKeys.all, 'detail'] as const,
detail: (bookId: APIBook['bookId']) =>
[...bookKeys.details(), bookId] as const,
bookmark: (bookId: APIBook['bookId']) =>
[...bookKeys.detail(bookId), 'bookmark'] as const,
};

export default bookKeys;
30 changes: 24 additions & 6 deletions src/queries/book/useBookInfoQuery.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { APIBook, APIBookDetail } from '@/types/book';
import useQueryWithSuspense, {
UseQueryOptionWithoutSuspense,
} from '@/hooks/useQueryWithSuspense';
import { UseQueryOptions } from '@tanstack/react-query';

import type { APIBook, APIBookDetail, BookDetail } from '@/types/book';

import bookAPI from '@/apis/book';
import useQueryWithSuspense from '@/hooks/useQueryWithSuspense';
import bookKeys from './key';

const useBookInfoQuery = (
const useBookInfoQuery = <TData = APIBookDetail>(
bookId: APIBook['bookId'],
options?: UseQueryOptionWithoutSuspense<APIBookDetail>
options?: UseQueryOptions<APIBookDetail, unknown, TData>
) =>
useQueryWithSuspense(
bookKeys.detail(bookId),
Expand All @@ -16,3 +17,20 @@ const useBookInfoQuery = (
);

export default useBookInfoQuery;

const transformBookData = (data: APIBookDetail) =>
({
bookId: data.bookId,
title: data.title,
author: data.author,
isbn: data.isbn,
summary: data.contents,
bookUrl: data.url,
imageUrl: data.imageUrl.replace('R120x174.q85', 'R300x0.q100'),
publisher: data.publisher,
} as BookDetail);

Comment on lines +21 to +31
Copy link
Member

Choose a reason for hiding this comment

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

question;

텐스택 쿼리 고수가 되신것같네요

순수하게 모르겠어서.. 질문드립니다...!
해당 transformBookData으로 어떤 작업을 할 수 있는지 궁금합니다..! 🙇

Copy link
Member Author

@gxxrxn gxxrxn Feb 5, 2024

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에서도 언급했었는데, 이 아티클을 참고했어요!

export const useBookInfo = (bookId: APIBook['bookId']) =>
useBookInfoQuery(bookId, {
select: transformBookData,
});
18 changes: 8 additions & 10 deletions src/queries/book/useBookUserInfoQuery.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { UseQueryOptions } from '@tanstack/react-query';
import { APIBookmarkedUserList } from '@/types/book';
import bookAPI from '@/apis/book';
import useQueryWithSuspense from '@/hooks/useQueryWithSuspense';
import bookKeys from './key';

const useBookUserInfoQuery = (
const useBookUserInfoQuery = <TData = APIBookmarkedUserList>(
bookId: number,
options?: Pick<
UseQueryOptions<
Awaited<ReturnType<typeof bookAPI.getBookUserInfo>>['data']
>,
'enabled'
>
options?: UseQueryOptions<APIBookmarkedUserList, unknown, TData>
) =>
useQuery(
['bookUserInfo', bookId],
useQueryWithSuspense(
bookKeys.bookmark(bookId),
() => bookAPI.getBookUserInfo(bookId).then(({ data }) => data),
options
);
Expand Down
15 changes: 15 additions & 0 deletions src/stories/book/detail/BookInfo.stories.tsx
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 },
};
11 changes: 11 additions & 0 deletions src/types/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export interface APIBookDetail extends APIBook {
imageKey: string;
}

export interface BookDetail {
bookId: APIBookDetail['bookId'];
title: APIBookDetail['title'];
author: APIBookDetail['author'];
isbn: APIBookDetail['isbn'];
summary: APIBookDetail['contents'];
bookUrl: APIBookDetail['url'];
imageUrl: APIBookDetail['imageUrl'];
publisher: APIBookDetail['publisher'];
}

export interface APIBookmarkedUserList {
bookId: APIBook['bookId'];
totalCount: number;
Expand Down
25 changes: 22 additions & 3 deletions src/v1/base/Avatar.tsx
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';
Expand Down Expand Up @@ -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
Copy link
Member

@hanyugeon hanyugeon Feb 4, 2024

Choose a reason for hiding this comment

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

ask;

해당 PR의 작업내용은 아니지만

Image컴포넌트에 src={src ?? FALLBACK_IMAGE_SRC}로 작성하면 useState를 사용하지 않을 수도 있을 것 같습니다. 라고 생각했지만 다시 생각 해보니

onError={setFallbackImage}를 통해 에러에 대한 부분을 확실하게 대처하고자 하는 규란님의 의도였는지 궁금해요 👀

Copy link
Member Author

Choose a reason for hiding this comment

The 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'}
Expand All @@ -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 };
4 changes: 2 additions & 2 deletions src/v1/book/BookCover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ const getCoverSize = (size: BookCoverSize) => {
}
case '2xlarge': {
return {
sizeClasses: 'w-[18.0rem] h-[25.2rem]',
sizeProps: { width: 180, height: 252 },
sizeClasses: 'w-[14.0rem] h-[19.6rem]',
sizeProps: { width: 140, height: 196 },
} as const;
}
}
Expand Down
103 changes: 103 additions & 0 deletions src/v1/book/detail/BookInfo.tsx
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
Copy link
Member

@hanyugeon hanyugeon Feb 4, 2024

Choose a reason for hiding this comment

The 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}&nbsp;...&nbsp;
{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}명이 이 책을 책장에 꽂았어요.`;
};
Loading