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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
env:
VITE_API_URL: ${{ secrets.VITE_API_URL }}
VITE_APP_URL: ${{ secrets.VITE_APP_URL }}
VITE_KAKAO_MAP_KEY: ${{ secrets.VITE_KAKAO_MAP_KEY }}

- name: Deploy to EC2
uses: appleboy/scp-action@v0.1.7
Expand Down
56 changes: 56 additions & 0 deletions src/features/topics/components/ConfirmedTopicList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll'

import type { ConfirmedTopicItem } from '../topics.types'
import EmptyTopicList from './EmptyTopicList'
import TopicCard from './TopicCard'
import TopicListSkeleton from './TopicListSkeleton'

type ConfirmedTopicListProps = {
topics: ConfirmedTopicItem[]
hasNextPage: boolean
isFetchingNextPage: boolean
onLoadMore: () => void
pageSize?: number
}

export default function ConfirmedTopicList({
topics,
hasNextPage,
isFetchingNextPage,
onLoadMore,
pageSize = 5,
}: ConfirmedTopicListProps) {
// 무한 스크롤: IntersectionObserver로 다음 페이지 로드
const observerRef = useInfiniteScroll(onLoadMore, {
hasNextPage,
isFetchingNextPage,
})

if (topics.length === 0) {
return <EmptyTopicList />
}

return (
<div className="flex flex-col gap-small">
<ul className="flex flex-col gap-small">
{topics.map((topic) => (
<li key={topic.topicId}>
<TopicCard
title={topic.title}
topicTypeLabel={topic.topicTypeLabel}
description={topic.description}
createdByNickname={topic.createdByInfo.nickname}
likeCount={topic.likeCount}
isLikeDisabled
/>
</li>
))}
</ul>
{/* 무한 스크롤 로딩 상태 */}
{isFetchingNextPage && <TopicListSkeleton count={pageSize} />}

{/* 무한 스크롤 트리거 */}
{hasNextPage && !isFetchingNextPage && <div ref={observerRef} className="h-4" />}
</div>
)
}
18 changes: 18 additions & 0 deletions src/features/topics/components/DefaultTopicCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Badge, Card } from '@/shared/ui'

export default function DefaultTopicCard() {
return (
<Card className="p-medium flex flex-col gap-small">
<div className="flex gap-xsmall items-center">
<p className="text-black typo-subtitle3">기본주제</p>
<Badge size="small" color="grey">
자유형
</Badge>
</div>
<p className="typo-body4 text-grey-700">자유롭게 이야기해봅시다.</p>
<div className="flex justify-between items-end">
<p className="typo-body6 text-grey-600">제안 : 도크도크</p>
</div>
</Card>
)
}
9 changes: 9 additions & 0 deletions src/features/topics/components/EmptyTopicList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Card } from '@/shared/ui'

export default function EmptyTopicList() {
return (
<Card className="p-base w-full flex items-center justify-center h-[160px]">
<p className="text-grey-600 typo-subtitle3 text-center">아직 확정된 주제가 없어요</p>
</Card>
)
}
67 changes: 67 additions & 0 deletions src/features/topics/components/ProposedTopicList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll'

import type { ProposedTopicItem } from '../topics.types'
import DefaultTopicCard from './DefaultTopicCard'
import TopicCard from './TopicCard'
import TopicListSkeleton from './TopicListSkeleton'

type ProposedTopicListProps = {
topics: ProposedTopicItem[]
hasNextPage: boolean
isFetchingNextPage: boolean
onLoadMore: () => void
pageSize?: number
gatheringId: number
meetingId: number
}

export default function ProposedTopicList({
topics,
hasNextPage,
isFetchingNextPage,
onLoadMore,
pageSize = 5,
gatheringId,
meetingId,
}: ProposedTopicListProps) {
// 무한 스크롤: IntersectionObserver로 다음 페이지 로드
const observerRef = useInfiniteScroll(onLoadMore, {
hasNextPage,
isFetchingNextPage,
})

return (
<div className="flex flex-col gap-small">
{/* 기본 주제는 항상 표시 */}
<DefaultTopicCard />

{/* 제안된 주제 목록 */}
{topics.length > 0 && (
<ul className="flex flex-col gap-small">
{topics.map((topic) => (
<li key={topic.topicId}>
<TopicCard
title={topic.title}
topicTypeLabel={topic.topicTypeLabel}
description={topic.description}
createdByNickname={topic.createdByInfo.nickname}
likeCount={topic.likeCount}
isLiked={topic.isLiked}
canDelete={topic.canDelete}
gatheringId={gatheringId}
meetingId={meetingId}
topicId={topic.topicId}
/>
</li>
))}
</ul>
)}

{/* 무한 스크롤 로딩 상태 */}
{isFetchingNextPage && <TopicListSkeleton count={pageSize} />}

{/* 무한 스크롤 트리거 */}
{hasNextPage && !isFetchingNextPage && <div ref={observerRef} className="h-4" />}
</div>
)
}
111 changes: 111 additions & 0 deletions src/features/topics/components/TopicCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Badge, Card, LikeButton, TextButton } from '@/shared/ui'
import { useGlobalModalStore } from '@/store'

import { useDeleteTopic, useLikeTopic } from '../hooks'

type TopicCardProps = {
title: string
topicTypeLabel: string
description: string
createdByNickname: string
likeCount: number
isLiked?: boolean
isLikeDisabled?: boolean
canDelete?: boolean
gatheringId?: number
meetingId?: number
topicId?: number
}

export default function TopicCard({
title,
topicTypeLabel,
description,
createdByNickname,
likeCount,
isLiked = false,
isLikeDisabled = false,
canDelete = false,
gatheringId,
meetingId,
topicId,
}: TopicCardProps) {
const { openConfirm, openAlert, openError } = useGlobalModalStore()
const deleteMutation = useDeleteTopic()
const likeMutation = useLikeTopic()

const handleDelete = async () => {
if (!gatheringId || !meetingId || !topicId) {
openError('삭제 실패', '주제 삭제에 필요한 정보가 없습니다.')
return
}

const confirmed = await openConfirm('주제 삭제', '정말 이 주제를 삭제하시겠습니까?', {
confirmText: '삭제',
variant: 'danger',
})

if (!confirmed) {
return
}

deleteMutation.mutate(
{ gatheringId, meetingId, topicId },
// TODO: 토스트 컴포넌트로 교체 예정
{
onSuccess: () => {
openAlert('삭제 완료', '주제가 삭제되었습니다.')
},
onError: (error) => {
openError('삭제 실패', error.userMessage)
},
}
)
}

const handleLike = () => {
if (!gatheringId || !meetingId || !topicId) {
return
}

if (likeMutation.isPending) return

likeMutation.mutate(
{ gatheringId, meetingId, topicId },
{
onError: (error) => {
// TODO: 토스트 컴포넌트로 교체 예정
alert(`좋아요 처리 중 오류가 발생했습니다: ${error.userMessage}`)
},
}
)
}
return (
<Card className="p-medium flex flex-col gap-small">
<div className="flex justify-between items-start">
<div className="flex gap-xsmall items-center">
<p className="text-black typo-subtitle3">{title}</p>
<Badge size="small" color="grey">
{topicTypeLabel}
</Badge>
</div>
{canDelete && (
<TextButton onClick={handleDelete} disabled={deleteMutation.isPending}>
삭제하기
</TextButton>
)}
</div>
<p className="typo-body4 text-grey-700">{description}</p>
<div className="flex justify-between items-end">
<p className="typo-body6 text-grey-600">제안 : {createdByNickname}</p>
<LikeButton
count={likeCount}
isLiked={isLiked}
disabled={isLikeDisabled}
isPending={likeMutation.isPending}
onClick={handleLike}
/>
</div>
</Card>
)
}
103 changes: 103 additions & 0 deletions src/features/topics/components/TopicHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { format } from 'date-fns'
import { Check } from 'lucide-react'

import { Button } from '@/shared/ui'

type ProposedHeaderProps = {
activeTab: 'PROPOSED'
actions: { canConfirm: boolean; canSuggest: boolean }
confirmedTopic: boolean
confirmedTopicDate: string | null
}

type ConfirmedHeaderProps = {
activeTab: 'CONFIRMED'
actions: { canViewPreOpinions: boolean; canWritePreOpinions: boolean }
confirmedTopic: boolean
confirmedTopicDate: string | null
}

type TopicHeaderProps = ProposedHeaderProps | ConfirmedHeaderProps

export default function TopicHeader(props: TopicHeaderProps) {
return (
<>
{/* 제안탭 */}
{props.activeTab === 'PROPOSED' && (
<div className="flex justify-between">
<div className="flex flex-col gap-tiny">
{props.confirmedTopic && props.confirmedTopicDate ? (
// 주제 확정됨
<>
<p className="text-black typo-subtitle3">
주제 제안이 마감되었어요. 확정된 주제를 확인해보세요!
</p>
<p className="typo-body4 text-grey-600">
{format(props.confirmedTopicDate, 'yyyy.MM.dd HH:mm')} 마감
</p>
</>
) : (
// 주제 제안 중
<>
<p className="text-black typo-subtitle3">
약속에서 나누고 싶은 주제를 제안해보세요
</p>
<p className="typo-body4 text-grey-600">
주제를 미리 정하면 우리 모임이 훨씬 풍성하고 즐거워질 거예요
</p>
</>
)}
</div>

<div className="flex gap-xsmall">
{props.actions.canConfirm && (
<Button variant="secondary" outline>
주제 확정하기
</Button>
)}

{props.actions.canSuggest && <Button>제안하기</Button>}
</div>
</div>
)}

{/* 제안탭 */}

{/* 확정탭 */}
{props.activeTab === 'CONFIRMED' && (
<div className="flex justify-between">
<div className="flex flex-col gap-tiny">
{props.confirmedTopic ? (
// 주제 확정됨
<>
<p className="flex items-center text-black typo-subtitle3 gap-tiny">
<Check size="20" /> 주제가 확정되었어요!
</p>
<p className="typo-body4 text-grey-600">
나의 생각을 미리 정리해서 공유하면 다른 멤버들의 의견도 바로 확인할 수 있어요
</p>
</>
) : (
// 주제 제안 중
<>
<p className="text-black typo-subtitle3">약속장이 주제를 선정하고 있어요</p>
<p className="typo-body4 text-grey-600">
주제가 확정되면 사전 의견을 남길 수 있는 창이 열려요
</p>
</>
)}
</div>

<div className="flex gap-xsmall">
<Button variant="secondary" outline disabled={!props.actions.canViewPreOpinions}>
사전 의견 확인하기
</Button>

<Button disabled={!props.actions.canWritePreOpinions}>사전 의견 작성하기</Button>
</div>
</div>
)}
{/* 확정탭 */}
</>
)
}
Loading
Loading