diff --git a/src/features/book/components/BookCarousel.tsx b/src/features/book/components/BookCarousel.tsx new file mode 100644 index 0000000..6671dbf --- /dev/null +++ b/src/features/book/components/BookCarousel.tsx @@ -0,0 +1,81 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' + +import { cn } from '@/shared/lib/utils' + +const THUMBNAIL_HEIGHT = 260 +const SCROLL_AMOUNT = 408 + +interface BookCarouselProps { + children: ReactNode + className?: string +} + +export default function BookCarousel({ children, className }: BookCarouselProps) { + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + + const updateScrollState = useCallback(() => { + const el = scrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 0) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) + }, []) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + updateScrollState() + + el.addEventListener('scroll', updateScrollState, { passive: true }) + const observer = new ResizeObserver(updateScrollState) + observer.observe(el) + + return () => { + el.removeEventListener('scroll', updateScrollState) + observer.disconnect() + } + }, [updateScrollState]) + + const scroll = (direction: 'left' | 'right') => { + const el = scrollRef.current + if (!el) return + const amount = direction === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT + el.scrollBy({ left: amount, behavior: 'smooth' }) + } + + return ( +
+
+ {children} +
+ + {/* 좌측 화살표 — 썸네일 영역 세로 중앙 기준 */} + {canScrollLeft && ( + + )} + + {/* 우측 화살표 */} + {canScrollRight && ( + + )} +
+ ) +} diff --git a/src/features/book/components/index.ts b/src/features/book/components/index.ts index 1379027..80dc554 100644 --- a/src/features/book/components/index.ts +++ b/src/features/book/components/index.ts @@ -1,4 +1,5 @@ export { default as BookCard } from './BookCard' +export { default as BookCarousel } from './BookCarousel' export { default as BookInfo } from './BookInfo' export { default as BookList } from './BookList' export { default as BookLogList } from './BookLogList' diff --git a/src/features/book/hooks/.gitkeep b/src/features/book/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/book/index.ts b/src/features/book/index.ts index 8f3ba92..bab2772 100644 --- a/src/features/book/index.ts +++ b/src/features/book/index.ts @@ -2,6 +2,7 @@ export * from './book.api' export * from './book.types' export { BookCard, + BookCarousel, BookInfo, BookList, BookLogList, diff --git a/src/features/gatherings/components/GatheringMeetingSection.tsx b/src/features/gatherings/components/GatheringMeetingSection.tsx index 2f92ba7..05f5854 100644 --- a/src/features/gatherings/components/GatheringMeetingSection.tsx +++ b/src/features/gatherings/components/GatheringMeetingSection.tsx @@ -1,9 +1,19 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' import { useUserProfile } from '@/features/user' import { PAGE_SIZES, ROUTES } from '@/shared/constants' -import { Button, Pagination, Spinner, Tabs, TabsList, TabsTrigger } from '@/shared/ui' +import { + Button, + Pagination, + Spinner, + Tabs, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/shared/ui' import type { GatheringUserRole, MeetingFilter } from '../gatherings.types' import { useGatheringMeetings, useMeetingTabCounts } from '../hooks' @@ -31,11 +41,22 @@ export default function GatheringMeetingSection({ currentUserRole, }: GatheringMeetingSectionProps) { const navigate = useNavigate() + const location = useLocation() const [activeTab, setActiveTab] = useState('ALL') const [currentPage, setCurrentPage] = useState(0) + const [showCreateTooltip, setShowCreateTooltip] = useState( + () => location.state?.justCreated === true + ) const isLeader = currentUserRole === 'LEADER' + // 모임 생성 직후 state를 소비한 뒤 히스토리에서 제거 (새로고침 시 재표시 방지) + useEffect(() => { + if (location.state?.justCreated) { + window.history.replaceState({}, '') + } + }, [location.state]) + // 현재 사용자 정보 const { data: currentUser } = useUserProfile() const currentUserNickname = currentUser?.nickname ?? '' @@ -124,9 +145,20 @@ export default function GatheringMeetingSection({ - + {showCreateTooltip ? ( + !open && setShowCreateTooltip(false)}> + + + + 약속을 만들어 함께 책을 읽어보세요! + + ) : ( + + )} )} diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts index 615edca..9915b1a 100644 --- a/src/features/meetings/hooks/index.ts +++ b/src/features/meetings/hooks/index.ts @@ -1,4 +1,5 @@ export * from './meetingQueryKeys' +export * from './myMeetingQueryKeys' export * from './useCancelJoinMeeting' export * from './useConfirmMeeting' export * from './useCreateMeeting' @@ -7,6 +8,8 @@ export * from './useJoinMeeting' export * from './useMeetingApprovals' export * from './useMeetingDetail' export * from './useMeetingForm' +export * from './useMyMeetings' +export * from './useMyMeetingTabCounts' export * from './usePlaceSearch' export * from './useRejectMeeting' export * from './useUpdateMeeting' diff --git a/src/features/meetings/hooks/myMeetingQueryKeys.ts b/src/features/meetings/hooks/myMeetingQueryKeys.ts new file mode 100644 index 0000000..cd6bfbc --- /dev/null +++ b/src/features/meetings/hooks/myMeetingQueryKeys.ts @@ -0,0 +1,8 @@ +import type { MyMeetingFilter } from '@/features/meetings/meetings.types' + +export const myMeetingQueryKeys = { + all: ['myMeetings'] as const, + lists: () => [...myMeetingQueryKeys.all, 'list'] as const, + list: (filter: MyMeetingFilter) => [...myMeetingQueryKeys.lists(), filter] as const, + tabCounts: () => [...myMeetingQueryKeys.all, 'tabCounts'] as const, +} diff --git a/src/features/meetings/hooks/useMyMeetingTabCounts.ts b/src/features/meetings/hooks/useMyMeetingTabCounts.ts new file mode 100644 index 0000000..a402ee0 --- /dev/null +++ b/src/features/meetings/hooks/useMyMeetingTabCounts.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' + +import { getMyMeetingTabCounts } from '../meetings.api' +import { myMeetingQueryKeys } from './myMeetingQueryKeys' + +export function useMyMeetingTabCounts() { + return useQuery({ + queryKey: myMeetingQueryKeys.tabCounts(), + queryFn: getMyMeetingTabCounts, + }) +} diff --git a/src/features/meetings/hooks/useMyMeetings.ts b/src/features/meetings/hooks/useMyMeetings.ts new file mode 100644 index 0000000..269fe68 --- /dev/null +++ b/src/features/meetings/hooks/useMyMeetings.ts @@ -0,0 +1,22 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getMyMeetings } from '../meetings.api' +import type { MyMeetingFilter, MyMeetingListResponse } from '../meetings.types' +import { myMeetingQueryKeys } from './myMeetingQueryKeys' + +export function useMyMeetings(filter: MyMeetingFilter) { + return useInfiniteQuery({ + queryKey: myMeetingQueryKeys.list(filter), + queryFn: ({ pageParam }) => + getMyMeetings({ + filter, + startDateTime: pageParam?.startDateTime, + meetingId: pageParam?.meetingId, + size: PAGE_SIZES.MY_MEETINGS, + }), + initialPageParam: undefined as MyMeetingListResponse['nextCursor'] | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), + }) +} diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts index deccde9..3e59a6f 100644 --- a/src/features/meetings/index.ts +++ b/src/features/meetings/index.ts @@ -17,10 +17,18 @@ export type { CreateMeetingResponse, GetMeetingApprovalsParams, GetMeetingDetailResponse, + GetMyMeetingsParams, MeetingApprovalItem as MeetingApprovalItemType, MeetingDetailActionStateType, MeetingLocation, MeetingStatus, + MyMeetingCursor, + MyMeetingFilter, + MyMeetingListItem, + MyMeetingListResponse, + MyMeetingProgressStatus, + MyMeetingRole, + MyMeetingTabCountsResponse, RejectMeetingResponse, UpdateMeetingRequest, UpdateMeetingResponse, diff --git a/src/features/meetings/meetings.api.ts b/src/features/meetings/meetings.api.ts index 1a193f9..e21ab9b 100644 --- a/src/features/meetings/meetings.api.ts +++ b/src/features/meetings/meetings.api.ts @@ -13,7 +13,10 @@ import type { CreateMeetingResponse, GetMeetingApprovalsParams, GetMeetingDetailResponse, + GetMyMeetingsParams, MeetingApprovalItem, + MyMeetingListResponse, + MyMeetingTabCountsResponse, RejectMeetingResponse, UpdateMeetingRequest, UpdateMeetingResponse, @@ -222,3 +225,24 @@ export const updateMeeting = async (meetingId: number, data: UpdateMeetingReques ) return response.data } + +/** + * 메인페이지 내 약속 리스트 조회 + * + * @param params - 조회 파라미터 (filter, cursor, size) + * @returns 내 약속 리스트 (커서 기반 페이지네이션) + */ +export const getMyMeetings = async ( + params: GetMyMeetingsParams +): Promise => { + return api.get(MEETINGS_ENDPOINTS.MY_MEETINGS, { params }) +} + +/** + * 메인페이지 내 약속 탭 카운트 조회 + * + * @returns 탭별 약속 카운트 (all, upcoming, done) + */ +export const getMyMeetingTabCounts = async (): Promise => { + return api.get(MEETINGS_ENDPOINTS.MY_MEETING_TAB_COUNTS) +} diff --git a/src/features/meetings/meetings.endpoints.ts b/src/features/meetings/meetings.endpoints.ts index 7485524..e153a18 100644 --- a/src/features/meetings/meetings.endpoints.ts +++ b/src/features/meetings/meetings.endpoints.ts @@ -27,4 +27,10 @@ export const MEETINGS_ENDPOINTS = { // 약속 수정 (PATCH /api/meetings/{meetingId}) UPDATE: (meetingId: number) => `${API_PATHS.MEETINGS}/${meetingId}`, + + // 메인페이지 내 약속 리스트 조회 (GET /api/meetings/me) + MY_MEETINGS: `${API_PATHS.MEETINGS}/me`, + + // 메인페이지 내 약속 탭 카운트 조회 (GET /api/meetings/me/tab-counts) + MY_MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/me/tab-counts`, } as const diff --git a/src/features/meetings/meetings.types.ts b/src/features/meetings/meetings.types.ts index 8d64d6b..35381f5 100644 --- a/src/features/meetings/meetings.types.ts +++ b/src/features/meetings/meetings.types.ts @@ -253,3 +253,61 @@ export type GetMeetingDetailResponse = { enabled: boolean } } + +// ============================================================ +// 메인페이지 내 약속 리스트 관련 타입 +// ============================================================ + +/** 메인페이지 약속 진행 상태 (시간 기준) */ +export type MyMeetingProgressStatus = 'UPCOMING' | 'ONGOING' | 'DONE' | 'UNKNOWN' + +/** 메인페이지 내 역할 */ +export type MyMeetingRole = 'LEADER' | 'GATHERING_LEADER' | 'MEMBER' | 'NONE' + +/** 메인페이지 약속 필터 */ +export type MyMeetingFilter = 'ALL' | 'UPCOMING' | 'DONE' + +/** 메인페이지 내 약속 아이템 */ +export interface MyMeetingListItem { + meetingId: number + meetingName: string + gatheringId: number + gatheringName: string + meetingLeaderName: string + bookName: string + startDateTime: string + endDateTime: string + meetingStatus: MeetingStatus | 'REJECTED' | 'DONE' + myRole: MyMeetingRole + progressStatus: MyMeetingProgressStatus +} + +/** 메인페이지 내 약속 커서 */ +export interface MyMeetingCursor { + startDateTime: string + meetingId: number +} + +/** 메인페이지 내 약속 리스트 응답 */ +export interface MyMeetingListResponse { + items: MyMeetingListItem[] + totalCount: number + pageSize: number + hasNext: boolean + nextCursor: MyMeetingCursor | null +} + +/** 메인페이지 내 약속 조회 파라미터 */ +export interface GetMyMeetingsParams { + filter: MyMeetingFilter + startDateTime?: string + meetingId?: number + size?: number +} + +/** 메인페이지 내 약속 탭 카운트 응답 */ +export interface MyMeetingTabCountsResponse { + all: number + upcoming: number + done: number +} diff --git a/src/features/topics/components/TopicHeader.tsx b/src/features/topics/components/TopicHeader.tsx index fdf7070..172becb 100644 --- a/src/features/topics/components/TopicHeader.tsx +++ b/src/features/topics/components/TopicHeader.tsx @@ -3,8 +3,7 @@ import { Check } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { ROUTES } from '@/shared/constants' -import { Button } from '@/shared/ui' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/Tooltip' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui' type ProposedHeaderProps = { activeTab: 'PROPOSED' diff --git a/src/pages/ComponentGuide/ComponentGuidePage.tsx b/src/pages/ComponentGuide/ComponentGuidePage.tsx index b4e5a86..6ba8b85 100644 --- a/src/pages/ComponentGuide/ComponentGuidePage.tsx +++ b/src/pages/ComponentGuide/ComponentGuidePage.tsx @@ -40,11 +40,13 @@ import { TabsTrigger, Textarea, TextButton, + Tooltip, + TooltipContent, + TooltipTrigger, TopicTypeSelectGroup, TopicTypeSelectItem, UserChip, } from '@/shared/ui' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/Tooltip' function ComponentGuidePage() { const [selectedSection, setSelectedSection] = useState('button') diff --git a/src/pages/Gatherings/CreateGatheringPage.tsx b/src/pages/Gatherings/CreateGatheringPage.tsx index 9effdff..e7bef2b 100644 --- a/src/pages/Gatherings/CreateGatheringPage.tsx +++ b/src/pages/Gatherings/CreateGatheringPage.tsx @@ -68,7 +68,9 @@ export default function CreateGatheringPage() { const handleComplete = () => { if (createdData?.gatheringId) { - navigate(ROUTES.GATHERING_DETAIL(createdData.gatheringId)) + navigate(ROUTES.GATHERING_DETAIL(createdData.gatheringId), { + state: { justCreated: true }, + }) } else { navigate(ROUTES.GATHERINGS) } diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx index c8c9393..f9e4a79 100644 --- a/src/pages/Home/HomePage.tsx +++ b/src/pages/Home/HomePage.tsx @@ -1,12 +1,32 @@ import { useUserProfile } from '@/features/user' +import FavoriteGatheringsSection from './components/FavoriteGatheringsSection' +import MyMeetingsSection from './components/MyMeetingsSection' +import ReadingBooksSection from './components/ReadingBooksSection' + export default function HomePage() { const { data: user } = useUserProfile() return ( -
-

안녕하세요, {user?.nickname ?? ''}님!

-

읽고 있는 책과 생각을 기록해보세요

+
+ {/* 인사말 */} +
+

+ {user ? `안녕하세요, ${user.nickname}님!` : '\u00A0'} +

+

읽고 있는 책과 생각을 기록해보세요

+
+ +
+ {/* 지금 읽고 있는 책 */} + + + {/* 내 약속 */} + + + {/* 즐겨찾는 모임 */} + +
) } diff --git a/src/pages/Home/components/FavoriteGatheringsSection.tsx b/src/pages/Home/components/FavoriteGatheringsSection.tsx new file mode 100644 index 0000000..4ac4d2c --- /dev/null +++ b/src/pages/Home/components/FavoriteGatheringsSection.tsx @@ -0,0 +1,61 @@ +import { useNavigate } from 'react-router-dom' + +import { GatheringCard, useFavoriteGatherings, useToggleFavorite } from '@/features/gatherings' +import { ROUTES } from '@/shared/constants' +import { useDeferredLoading } from '@/shared/hooks' + +import HomeSectionHeader from './HomeSectionHeader' + +export default function FavoriteGatheringsSection() { + const navigate = useNavigate() + const { data, isLoading } = useFavoriteGatherings() + const showSkeleton = useDeferredLoading(isLoading) + const { mutate: toggleFavorite } = useToggleFavorite() + + const gatherings = data?.gatherings ?? [] + + return ( +
+ + + {showSkeleton ? ( +
+ {[...Array(3).keys()].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : !data ? null : gatherings.length === 0 ? ( +
+

즐겨찾기한 모임이 없어요.

+

+ 자주 찾는 모임을 등록하고 한눈에 확인해 보세요. +

+
+ ) : ( +
+ {gatherings.map((gathering) => ( + navigate(ROUTES.GATHERING_DETAIL(gathering.gatheringId))} + /> + ))} +
+ )} +
+ ) +} diff --git a/src/pages/Home/components/HomeBookCard.tsx b/src/pages/Home/components/HomeBookCard.tsx new file mode 100644 index 0000000..cde53ec --- /dev/null +++ b/src/pages/Home/components/HomeBookCard.tsx @@ -0,0 +1,32 @@ +import { Link } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants' + +interface HomeBookCardProps { + bookId: number + title: string + authors: string + thumbnail: string +} + +export default function HomeBookCard({ bookId, title, authors, thumbnail }: HomeBookCardProps) { + return ( + + {/* 썸네일 */} +
+ {`${title} +
+ + {/* 책 정보 */} +
+

{title}

+

{authors}

+
+ + ) +} diff --git a/src/pages/Home/components/HomeMeetingCard.tsx b/src/pages/Home/components/HomeMeetingCard.tsx new file mode 100644 index 0000000..507be72 --- /dev/null +++ b/src/pages/Home/components/HomeMeetingCard.tsx @@ -0,0 +1,127 @@ +import { differenceInCalendarDays, format } from 'date-fns' +import { ko } from 'date-fns/locale' +import type { MouseEvent } from 'react' +import { useNavigate } from 'react-router-dom' + +import type { MyMeetingListItem } from '@/features/meetings' +import { ROUTES } from '@/shared/constants' +import { cn } from '@/shared/lib/utils' +import { Badge, Button } from '@/shared/ui' + +interface HomeMeetingCardProps { + meeting: MyMeetingListItem +} + +function formatMeetingDate(dateStr: string) { + return format(new Date(dateStr), 'yy.MM.dd(eee) HH:mm', { locale: ko }) +} + +function getDDay(startDateTime: string): string { + const diffDays = differenceInCalendarDays(new Date(startDateTime), new Date()) + if (diffDays === 0) return 'D-Day' + if (diffDays > 0) return `D-${diffDays}` + return '' +} + +const STATUS_CONFIG = { + ONGOING: { label: '약속 중', color: 'red' as const }, + UPCOMING: { label: '예정', color: 'yellow' as const }, + DONE: { label: '종료', color: 'grey' as const }, + UNKNOWN: { label: '', color: 'grey' as const }, +} + +export default function HomeMeetingCard({ meeting }: HomeMeetingCardProps) { + const navigate = useNavigate() + const { + meetingId, + meetingName, + gatheringId, + gatheringName, + startDateTime, + endDateTime, + myRole, + progressStatus, + } = meeting + + const status = STATUS_CONFIG[progressStatus] + const isOngoing = progressStatus === 'ONGOING' + const isUpcoming = progressStatus === 'UPCOMING' + const isDone = progressStatus === 'DONE' + const isLeader = myRole === 'LEADER' + const dDay = isUpcoming ? getDDay(startDateTime) : '' + + const handleCardClick = () => { + navigate(ROUTES.MEETING_DETAIL(gatheringId, meetingId)) + } + + const handleActionClick = (e: MouseEvent) => { + e.stopPropagation() + if (isUpcoming) { + navigate(ROUTES.PRE_OPINIONS(gatheringId, meetingId)) + } + // TODO: 종료 → 개인 회고 작성 페이지 연결 + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleCardClick() + } + }} + > + {/* 상태 태그 */} +
+ {status.label && ( + + {status.label} + + )} +
+ + {/* 약속 정보 */} +
+
+

+ {gatheringName} | {meetingName} +

+ {isLeader && ( + + 약속장 + + )} +
+
+ {dDay && {dDay}} + + {formatMeetingDate(startDateTime)} ~ {formatMeetingDate(endDateTime)} + +
+
+ + {/* 액션 버튼 */} + {/* TODO: API 응답에 hasPreOpinionTemplate 필드 추가 후, 템플릿 미제작 시 disabled 처리 */} + {isUpcoming && ( + + )} + {isDone && ( + // TODO: 개인 회고 작성 페이지 연결 후 disabled 제거 + + )} +
+ ) +} diff --git a/src/pages/Home/components/HomeSectionHeader.tsx b/src/pages/Home/components/HomeSectionHeader.tsx new file mode 100644 index 0000000..2eca4d4 --- /dev/null +++ b/src/pages/Home/components/HomeSectionHeader.tsx @@ -0,0 +1,40 @@ +import { ChevronRight } from 'lucide-react' +import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' + +import { cn } from '@/shared/lib/utils' + +interface HomeSectionHeaderProps { + title: string + linkTo?: string + linkLabel?: string + children?: ReactNode + className?: string +} + +export default function HomeSectionHeader({ + title, + linkTo, + linkLabel, + children, + className, +}: HomeSectionHeaderProps) { + return ( +
+
+

{title}

+ {children} +
+ + {linkTo && linkLabel && ( + + {linkLabel} + + + )} +
+ ) +} diff --git a/src/pages/Home/components/MyMeetingsSection.tsx b/src/pages/Home/components/MyMeetingsSection.tsx new file mode 100644 index 0000000..d1f1922 --- /dev/null +++ b/src/pages/Home/components/MyMeetingsSection.tsx @@ -0,0 +1,116 @@ +import { ChevronDown, ChevronUp } from 'lucide-react' +import { useState } from 'react' + +import { type MyMeetingFilter, useMyMeetings, useMyMeetingTabCounts } from '@/features/meetings' +import { PAGE_SIZES } from '@/shared/constants' +import { useDeferredLoading } from '@/shared/hooks' +import { Tabs, TabsList, TabsTrigger } from '@/shared/ui' + +import HomeMeetingCard from './HomeMeetingCard' +import HomeSectionHeader from './HomeSectionHeader' + +export default function MyMeetingsSection() { + const [activeTab, setActiveTab] = useState('ALL') + const [isCollapsed, setIsCollapsed] = useState(false) + + const { data: tabCounts } = useMyMeetingTabCounts() + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useMyMeetings(activeTab) + const showSkeleton = useDeferredLoading(isLoading) + + const allItems = data?.pages.flatMap((page) => page.items) ?? [] + const hasMultiplePages = (data?.pages.length ?? 0) > 1 + const displayItems = isCollapsed ? allItems.slice(0, PAGE_SIZES.MY_MEETINGS) : allItems + + const handleTabChange = (value: string) => { + setActiveTab(value as MyMeetingFilter) + setIsCollapsed(false) + } + + const handleExpand = () => { + if (hasNextPage) { + fetchNextPage() + } + setIsCollapsed(false) + } + + const handleCollapse = () => { + setIsCollapsed(true) + } + + return ( +
+ + + + + 전체 + + + 다가오는 약속 + + + 종료된 약속 + + + + + + {showSkeleton ? ( +
+ {[...Array(PAGE_SIZES.MY_MEETINGS).keys()].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : allItems.length === 0 ? ( +
+

참여 중인 약속이 없어요

+
+ ) : ( +
+ {/* 약속 리스트 — 최대 높이 392px, 스크롤 */} +
+ {displayItems.map((meeting) => ( + + ))} + {isFetchingNextPage && ( +
+

불러오는 중...

+
+ )} +
+ + {/* 펼치기 / 접기 버튼 */} + {(hasNextPage || hasMultiplePages) && ( + + )} +
+ )} +
+ ) +} diff --git a/src/pages/Home/components/ReadingBooksSection.tsx b/src/pages/Home/components/ReadingBooksSection.tsx new file mode 100644 index 0000000..b416ea2 --- /dev/null +++ b/src/pages/Home/components/ReadingBooksSection.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { BookCarousel, useBooks } from '@/features/book' +import { ROUTES } from '@/shared/constants' +import { useDeferredLoading } from '@/shared/hooks' +import { Button, Tabs, TabsList, TabsTrigger } from '@/shared/ui' + +import HomeBookCard from './HomeBookCard' +import HomeSectionHeader from './HomeSectionHeader' + +type BookTab = 'all' | 'pre' | 'post' + +export default function ReadingBooksSection() { + // TODO: pre/post 탭 활성화 시 activeTab을 useBooks filter 파라미터로 연결 필요 + const [activeTab, setActiveTab] = useState('all') + const navigate = useNavigate() + + const { data, isLoading } = useBooks({ status: 'READING' }) + const showSkeleton = useDeferredLoading(isLoading) + + const books = data?.pages.flatMap((page) => page.items) ?? [] + const totalCount = data?.pages[0]?.readingCount ?? 0 + + return ( +
+ + setActiveTab(v as BookTab)}> + + + 전체 + + + 약속 전 + + + 약속 후 + + + + + + {showSkeleton ? ( +
+ {[...Array(6).keys()].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : books.length === 0 ? ( +
+
+

내 책장이 비어있어요.

+

+ 첫 번째 책을 등록하고 독서 기록을 시작해 보세요! +

+
+ +
+ ) : ( + + {books.map((book) => ( + + ))} + + )} +
+ ) +} diff --git a/src/shared/constants/pagination.ts b/src/shared/constants/pagination.ts index 4de896c..32b2a48 100644 --- a/src/shared/constants/pagination.ts +++ b/src/shared/constants/pagination.ts @@ -28,4 +28,6 @@ export const PAGE_SIZES = { GATHERING_BOOKS: 12, /** 모임 멤버 목록 페이지 사이즈 */ GATHERING_MEMBERS: 9, + /** 내 약속 목록 페이지 사이즈 */ + MY_MEETINGS: 4, } as const diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 1dfcf26..16f1cb1 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useDebounce' +export * from './useDeferredLoading' export * from './useInfiniteScroll' export * from './useScrollCollapse' diff --git a/src/shared/hooks/useDeferredLoading.ts b/src/shared/hooks/useDeferredLoading.ts new file mode 100644 index 0000000..1284403 --- /dev/null +++ b/src/shared/hooks/useDeferredLoading.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * 로딩 상태의 깜빡임을 방지하는 훅 + * + * - delay: 로딩이 시작된 후 스켈레톤을 보여주기까지 대기하는 시간 (ms) + * - minDuration: 스켈레톤이 한 번 보이면 최소한 유지되는 시간 (ms) + * + * 빠른 로딩(delay 이내 완료)에는 스켈레톤이 아예 보이지 않고, + * 느린 로딩에는 스켈레톤이 최소 minDuration 동안 안정적으로 보입니다. + */ +export function useDeferredLoading( + isLoading: boolean, + { delay = 200, minDuration = 500 } = {} +): boolean { + const [showSkeleton, setShowSkeleton] = useState(false) + const showTimeRef = useRef(null) + + useEffect(() => { + let delayTimer: ReturnType + let minDurationTimer: ReturnType + let hideTimer: ReturnType + + if (isLoading) { + delayTimer = setTimeout(() => { + showTimeRef.current = Date.now() + setShowSkeleton(true) + }, delay) + } else { + if (showTimeRef.current !== null) { + const elapsed = Date.now() - showTimeRef.current + const remaining = Math.max(minDuration - elapsed, 0) + + minDurationTimer = setTimeout(() => { + setShowSkeleton(false) + showTimeRef.current = null + }, remaining) + } else { + hideTimer = setTimeout(() => { + setShowSkeleton(false) + }, 0) + } + } + + return () => { + clearTimeout(delayTimer) + clearTimeout(minDurationTimer) + clearTimeout(hideTimer) + } + }, [isLoading, delay, minDuration]) + + return showSkeleton +} diff --git a/src/store/.gitkeep b/src/store/.gitkeep deleted file mode 100644 index e69de29..0000000