Skip to content
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
81 changes: 81 additions & 0 deletions src/features/book/components/BookCarousel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className={cn('group/carousel relative', className)}>
<div ref={scrollRef} className="flex gap-large overflow-x-auto scrollbar-hide">
{children}
</div>

{/* 좌측 화살표 — 썸네일 영역 세로 중앙 기준 */}
{canScrollLeft && (
<button
type="button"
onClick={() => scroll('left')}
className="absolute left-0 z-10 hidden size-10 cursor-pointer items-center justify-center rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-opacity group-hover/carousel:flex md:flex"
style={{ top: THUMBNAIL_HEIGHT / 2, transform: 'translate(-50%, -50%)' }}
aria-label="이전"
>
<ChevronLeft className="size-5 text-grey-700" />
</button>
)}

{/* 우측 화살표 */}
{canScrollRight && (
<button
type="button"
onClick={() => scroll('right')}
className="absolute right-0 z-10 hidden size-10 cursor-pointer items-center justify-center rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-opacity group-hover/carousel:flex md:flex"
style={{ top: THUMBNAIL_HEIGHT / 2, transform: 'translate(50%, -50%)' }}
aria-label="다음"
>
<ChevronRight className="size-5 text-grey-700" />
</button>
)}
</div>
)
}
1 change: 1 addition & 0 deletions src/features/book/components/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Empty file removed src/features/book/hooks/.gitkeep
Empty file.
1 change: 1 addition & 0 deletions src/features/book/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './book.api'
export * from './book.types'
export {
BookCard,
BookCarousel,
BookInfo,
BookList,
BookLogList,
Expand Down
44 changes: 38 additions & 6 deletions src/features/gatherings/components/GatheringMeetingSection.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,11 +41,22 @@ export default function GatheringMeetingSection({
currentUserRole,
}: GatheringMeetingSectionProps) {
const navigate = useNavigate()
const location = useLocation()
const [activeTab, setActiveTab] = useState<MeetingFilter>('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 ?? ''
Expand Down Expand Up @@ -124,9 +145,20 @@ export default function GatheringMeetingSection({
<Button variant="secondary" outline size="small" onClick={handleMeetingSettings}>
약속 설정
</Button>
<Button size="small" onClick={handleCreateMeeting}>
약속 만들기
</Button>
{showCreateTooltip ? (
<Tooltip dismissable onOpenChange={(open) => !open && setShowCreateTooltip(false)}>
<TooltipTrigger asChild>
<Button size="small" onClick={handleCreateMeeting}>
약속 만들기
</Button>
</TooltipTrigger>
<TooltipContent>약속을 만들어 함께 책을 읽어보세요!</TooltipContent>
</Tooltip>
) : (
<Button size="small" onClick={handleCreateMeeting}>
약속 만들기
</Button>
)}
</div>
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/features/meetings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './meetingQueryKeys'
export * from './myMeetingQueryKeys'
export * from './useCancelJoinMeeting'
export * from './useConfirmMeeting'
export * from './useCreateMeeting'
Expand All @@ -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'
8 changes: 8 additions & 0 deletions src/features/meetings/hooks/myMeetingQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -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,
}
11 changes: 11 additions & 0 deletions src/features/meetings/hooks/useMyMeetingTabCounts.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
22 changes: 22 additions & 0 deletions src/features/meetings/hooks/useMyMeetings.ts
Original file line number Diff line number Diff line change
@@ -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),
})
}
8 changes: 8 additions & 0 deletions src/features/meetings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/features/meetings/meetings.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type {
CreateMeetingResponse,
GetMeetingApprovalsParams,
GetMeetingDetailResponse,
GetMyMeetingsParams,
MeetingApprovalItem,
MyMeetingListResponse,
MyMeetingTabCountsResponse,
RejectMeetingResponse,
UpdateMeetingRequest,
UpdateMeetingResponse,
Expand Down Expand Up @@ -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<MyMeetingListResponse> => {
return api.get<MyMeetingListResponse>(MEETINGS_ENDPOINTS.MY_MEETINGS, { params })
}

/**
* 메인페이지 내 약속 탭 카운트 조회
*
* @returns 탭별 약속 카운트 (all, upcoming, done)
*/
export const getMyMeetingTabCounts = async (): Promise<MyMeetingTabCountsResponse> => {
return api.get<MyMeetingTabCountsResponse>(MEETINGS_ENDPOINTS.MY_MEETING_TAB_COUNTS)
}
6 changes: 6 additions & 0 deletions src/features/meetings/meetings.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 58 additions & 0 deletions src/features/meetings/meetings.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 1 addition & 2 deletions src/features/topics/components/TopicHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion src/pages/ComponentGuide/ComponentGuidePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('button')
Expand Down
4 changes: 3 additions & 1 deletion src/pages/Gatherings/CreateGatheringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading