diff --git a/src/constants/dataUrl.ts b/src/constants/dataUrl.ts new file mode 100644 index 00000000..39bbe258 --- /dev/null +++ b/src/constants/dataUrl.ts @@ -0,0 +1,4 @@ +export const DATA_URL = { + placeholder: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjWL9+/X8ABysDDapsaG4AAAAASUVORK5CYII=', // data url for placeholder color (#AFAFAF) +}; diff --git a/src/stories/bookgroup/BookGroupStatus.stories.tsx b/src/stories/bookgroup/BookGroupStatus.stories.tsx new file mode 100644 index 00000000..04fc5535 --- /dev/null +++ b/src/stories/bookgroup/BookGroupStatus.stories.tsx @@ -0,0 +1,19 @@ +import BookGroupStatus from '@/ui/bookgroup/BookGroupStatus'; +import { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'bookgroup/BookGroupStatus', + component: BookGroupStatus, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + start: '2023-12-31', + end: '2024-01-08', + }, +}; diff --git a/src/stories/bookgroup/detail/BookGroupInfo.stories.tsx b/src/stories/bookgroup/detail/BookGroupInfo.stories.tsx new file mode 100644 index 00000000..4fd3cddb --- /dev/null +++ b/src/stories/bookgroup/detail/BookGroupInfo.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import BookGroupInfo from '@/ui/bookgroup/detail/BookGroupInfo'; + +const meta: Meta = { + title: 'bookgroup/detail/BookGroupInfo', + component: BookGroupInfo, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: '프롱이 리팩터링 스터디', + description: + '제 1차 프롱이 기수연합 독서 스터디 입니다. 마틴 파울러의 저서 ‘리팩터링 2판’과 함께 진행합니다.', + book: { + title: '리팩터링 2판', + author: '마틴 파울러', + bookImageSrc: 'https://image.yes24.com/goods/89649360/XL', + }, + date: { + start: '2023-10-31', + end: '2023-11-27', + }, + memberCount: { + current: 3, + max: 20, + }, + owner: { + isMe: true, + name: '소피아', + profileImageSrc: '/icons/logo.svg', + }, + isPublic: false, + }, +}; diff --git a/src/types/dday.ts b/src/types/dday.ts new file mode 100644 index 00000000..d7ba22ce --- /dev/null +++ b/src/types/dday.ts @@ -0,0 +1 @@ +export type DdayStatus = 'before' | 'dday' | 'ongoing' | 'end'; diff --git a/src/ui/BookArchive/BookArchiveForUnAuth.tsx b/src/ui/BookArchive/BookArchiveForUnAuth.tsx index f8abdb94..6c69f763 100644 --- a/src/ui/BookArchive/BookArchiveForUnAuth.tsx +++ b/src/ui/BookArchive/BookArchiveForUnAuth.tsx @@ -17,7 +17,7 @@ const BookArchiveForUnAuth = () => { if (!isSuccess) return null; return ( -
+
🔥 인기 책장
{data.bookshelfResponses.map(bookshelf => ( diff --git a/src/ui/book/BookCover.tsx b/src/ui/book/BookCover.tsx new file mode 100644 index 00000000..312c8276 --- /dev/null +++ b/src/ui/book/BookCover.tsx @@ -0,0 +1,61 @@ +import { ComponentPropsWithoutRef } from 'react'; +import Image from 'next/image'; + +import { DATA_URL } from '@/constants/dataUrl'; + +type BookCoverSize = + | 'xsmall' + | 'small' + | 'medium' + | 'large' + | 'xlarge' + | '2xlarge'; + +type BookCoverProps = Required< + Pick, 'src'> +> & { + title: string; + size?: BookCoverSize; +}; + +const getCoverSizeClasses = (size: BookCoverSize) => { + switch (size) { + case 'xsmall': { + return 'w-[6.5rem] h-[9.1rem]'; + } + case 'small': { + return 'w-[7.0rem] h-[9.8rem]'; + } + case 'medium': { + return 'w-[7.5rem] h-[10.5rem]'; + } + case 'large': { + return 'w-[9.0rem] h-[12.6rem]'; + } + case 'xlarge': { + return 'w-[11.0rem] h-[15.4rem]'; + } + case '2xlarge': { + return 'w-[18.0rem] h-[25.2rem]'; + } + } +}; + +const BookCover = ({ src, title, size = 'medium' }: BookCoverProps) => { + const sizeClasses = getCoverSizeClasses(size); + + return ( + + {title} + + ); +}; + +export default BookCover; diff --git a/src/ui/bookgroup/BookGroupStatus.tsx b/src/ui/bookgroup/BookGroupStatus.tsx new file mode 100644 index 00000000..336e07c6 --- /dev/null +++ b/src/ui/bookgroup/BookGroupStatus.tsx @@ -0,0 +1,57 @@ +import { DdayStatus } from '@/types/dday'; + +import Badge from '@/ui/Base/Badge'; +import { getDdayCount } from '@/utils/date'; + +const getDdayStatus = (ddayByStart: number, ddayByEnd: number) => { + if (ddayByStart > 0) { + return 'before' as const; + } else if (ddayByStart === 0 && ddayByEnd > 0) { + return 'dday' as const; + } else if (ddayByStart < 0 && ddayByEnd >= 0) { + return 'ongoing' as const; + } else { + return 'end' as const; + } +}; + +const getBadgeProps = (status: DdayStatus, ddayCount: number) => { + switch (status) { + case 'before': + return { + colorScheme: 'main' as const, + isFilled: true, + text: `D-${ddayCount}`, + }; + case 'dday': + return { + colorScheme: 'main' as const, + isFilled: false, + text: 'D-day', + }; + case 'ongoing': + return { + colorScheme: 'main' as const, + isFilled: true, + text: '진행중', + }; + case 'end': + return { + colorScheme: 'grey' as const, + isFilled: true, + text: '모임종료', + }; + } +}; + +const BookGroupStatus = ({ start, end }: { start: string; end: string }) => { + const ddayByStart = getDdayCount(new Date(start)); + const ddayByEnd = getDdayCount(new Date(end)); + const ddayStatus = getDdayStatus(ddayByStart, ddayByEnd); + + const { text, ...badgeProps } = getBadgeProps(ddayStatus, ddayByStart); + + return {text}; +}; + +export default BookGroupStatus; diff --git a/src/ui/bookgroup/detail/BookGroupInfo.tsx b/src/ui/bookgroup/detail/BookGroupInfo.tsx new file mode 100644 index 00000000..f52214a6 --- /dev/null +++ b/src/ui/bookgroup/detail/BookGroupInfo.tsx @@ -0,0 +1,139 @@ +import Image from 'next/image'; + +import Badge from '@/ui/Base/Badge'; +import BookCover from '@/ui/book/BookCover'; +import BookGroupStatus from '@/ui/bookgroup/BookGroupStatus'; +import { IconArrowLeft, IconCalendar, IconMembers } from '@public/icons'; +import { DATA_URL } from '@/constants/dataUrl'; + +interface BookGroupInfoProps { + title: string; + description: string; + book: { title: string; author: string; bookImageSrc: string }; + date: { start: string; end: string }; + memberCount: { current: number; max: number }; + owner: { isMe: boolean; name: string; profileImageSrc: string }; + isPublic: boolean; +} + +const BookGroupInfo = ({ + title, + description, + book, + date, + memberCount, + owner, + isPublic, +}: BookGroupInfoProps) => { + return ( +
+
+ + +
+ + + <BookInfoCard + title={book.title} + bookImageSrc={book.bookImageSrc} + author={book.author} + /> + <div className="flex flex-col gap-[0.3rem]"> + <Duration start={date.start} end={date.end} /> + <MemberCapacity current={memberCount.current} max={memberCount.max} /> + </div> + <Description content={description} /> + </div> + ); +}; + +export default BookGroupInfo; + +const Public = ({ isPublic }: { isPublic: boolean }) => ( + <Badge colorScheme="grey">{isPublic ? '공개' : '비공개'}</Badge> +); + +const Owner = ({ + profileImageSrc, + name, + isMe, +}: { + profileImageSrc: string; + name: string; + isMe: boolean; +}) => { + return ( + <div className="flex items-center gap-[1rem]"> + {/** FIXME: Avatar 컴포넌트로 변경 */} + <Image + width={32} + height={32} + alt={name} + src={profileImageSrc} + className="rounded-full" + placeholder="blur" + blurDataURL={DATA_URL['placeholder']} + /> + <span className="text-center text-sm font-bold"> + {name} {isMe && ' 👑'} + </span> + </div> + ); +}; + +const Title = ({ title }: { title: string }) => { + return <p className="text-xl font-bold">{title}</p>; +}; + +const BookInfoCard = ({ + bookImageSrc, + title, + author, +}: { + bookImageSrc: string; + title: string; + author: string; +}) => { + return ( + <div className="flex min-h-[10rem] w-full cursor-pointer gap-[2.4rem] rounded-[0.5rem] border-[0.05rem] border-cancel px-[2.2rem] py-[1.8rem]"> + <BookCover size="xsmall" src={bookImageSrc} title={title} /> + <div className="flex flex-grow flex-col"> + <span className="text-sm font-bold">{title}</span> + <span className="text-xs text-placeholder">{author}</span> + </div> + {/** 왼쪽 방향의 화살표를 180도 회전하여 사용 */} + <IconArrowLeft className="h-[1.5rem] w-[1.5rem] rotate-180" /> + </div> + ); +}; + +const Duration = ({ start, end }: { start: string; end: string }) => { + return ( + <div className="flex items-center gap-[1rem]"> + <IconCalendar className="h-auto w-[1.6rem] fill-placeholder" /> + <span className="text-sm text-placeholder"> + {start} - {end} + </span> + </div> + ); +}; + +const MemberCapacity = ({ current, max }: { current: number; max: number }) => { + return ( + <div className="flex items-center gap-[1rem]"> + <IconMembers className="h-auto w-[1.6rem] fill-placeholder" /> + <span className="text-sm text-placeholder"> + <span className="text-main-900">{current}</span> + {` / ${max}명`} + </span> + </div> + ); +}; + +const Description = ({ content }: { content: string }) => { + return <p className="text-md leading-snug">{content}</p>; +}; diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 00000000..f70e6a2a --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,5 @@ +export const toDayFromMillseconds = (value: number) => + Math.ceil(value / (1000 * 60 * 60 * 24)); + +export const getDdayCount = (target: Date) => + toDayFromMillseconds(target.getTime() - new Date().getTime()); diff --git a/tailwind.config.js b/tailwind.config.js index c6eb935c..07626818 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -73,6 +73,10 @@ module.exports = { fontFamily: { lineseed: ['var(--font-lineseed)'], }, + boxShadow: { + bookcover: + '0px 0px 2px rgba(0, 0, 0, 0.2), 2px 2px 6px rgba(0, 0, 0, 0.1)', + }, keyframes: { 'page-transition': { from: { opacity: 0 },