diff --git a/src/features/retrospectives/components/AiGradientIcon.tsx b/src/features/retrospectives/components/AiGradientIcon.tsx new file mode 100644 index 0000000..551044f --- /dev/null +++ b/src/features/retrospectives/components/AiGradientIcon.tsx @@ -0,0 +1,59 @@ +import { useId } from 'react' + +type AiGradientIconProps = { + className?: string +} + +export default function AiGradientIcon({ className = 'size-6' }: AiGradientIconProps) { + const gradientId = `${useId()}-ai-gradient` + + return ( + + + + + + + + {/* 큰 별 */} + + {/* 우상단 세로 선 */} + + {/* 우상단 가로 선 */} + + {/* 좌하단 작은 원 */} + + + ) +} diff --git a/src/features/retrospectives/components/AiLoadingOverlay.tsx b/src/features/retrospectives/components/AiLoadingOverlay.tsx new file mode 100644 index 0000000..1cc57f8 --- /dev/null +++ b/src/features/retrospectives/components/AiLoadingOverlay.tsx @@ -0,0 +1,36 @@ +import { createPortal } from 'react-dom' + +import { Button } from '@/shared/ui' + +import AiGradientIcon from './AiGradientIcon' + +type AiLoadingOverlayProps = { + isOpen: boolean + message?: string + onCancel?: () => void +} + +export default function AiLoadingOverlay({ + isOpen, + message = 'AI가 사전 의견을 요약 중이에요', + onCancel, +}: AiLoadingOverlayProps) { + if (!isOpen) return null + + return createPortal( +
+
+
+ +

{message}

+
+ {onCancel && ( + + )} +
+
, + document.body + ) +} diff --git a/src/features/retrospectives/components/AiSummaryToast.tsx b/src/features/retrospectives/components/AiSummaryToast.tsx new file mode 100644 index 0000000..73d154f --- /dev/null +++ b/src/features/retrospectives/components/AiSummaryToast.tsx @@ -0,0 +1,67 @@ +import { Check } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +type AiSummaryToastProps = { + isVisible: boolean + message?: string + onDismiss: () => void + duration?: number +} + +export default function AiSummaryToast({ + isVisible, + message = '독서 모임 내용 요약이 완료됐어요', + onDismiss, + duration = 3000, +}: AiSummaryToastProps) { + const [opacity, setOpacity] = useState(false) + const onDismissRef = useRef(onDismiss) + const fadeOutTimerRef = useRef | null>(null) + + useEffect(() => { + onDismissRef.current = onDismiss + }) + + useEffect(() => { + if (!isVisible) return + + const fadeInTimer = requestAnimationFrame(() => { + requestAnimationFrame(() => { + setOpacity(true) + }) + }) + + const dismissTimer = setTimeout(() => { + setOpacity(false) + fadeOutTimerRef.current = setTimeout(() => onDismissRef.current(), 300) + }, duration) + + return () => { + cancelAnimationFrame(fadeInTimer) + clearTimeout(dismissTimer) + if (fadeOutTimerRef.current) clearTimeout(fadeOutTimerRef.current) + setOpacity(false) + } + }, [isVisible, duration]) + + if (!isVisible) return null + + return createPortal( +
+
+
+ +

{message}

+
+
+
, + document.body + ) +} diff --git a/src/features/retrospectives/components/RetrospectiveCardButtons.tsx b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx new file mode 100644 index 0000000..d390d09 --- /dev/null +++ b/src/features/retrospectives/components/RetrospectiveCardButtons.tsx @@ -0,0 +1,53 @@ +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants' + +interface RetrospectiveCardButtonsProps { + gatheringId: number + meetingId: number +} + +export default function RetrospectiveCardButtons({ + gatheringId, + meetingId, +}: RetrospectiveCardButtonsProps) { + const navigate = useNavigate() + + return ( +
+ {/* 약속 회고 카드 */} + + + {/* 개인 회고 카드 */} + +
+ ) +} diff --git a/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx new file mode 100644 index 0000000..f08e760 --- /dev/null +++ b/src/features/retrospectives/components/RetrospectiveSummarySkeleton.tsx @@ -0,0 +1,37 @@ +import { Card } from '@/shared/ui' + +type RetrospectiveSummarySkeletonProps = { + count?: number +} + +export default function RetrospectiveSummarySkeleton({ + count = 3, +}: RetrospectiveSummarySkeletonProps) { + return ( +
+ {[...Array(count).keys()].map((i) => ( + +
+ {/* 토픽 헤더 */} +
+
+
+
+ {/* 요약 텍스트 */} +
+
+
+
+
+ {/* 키포인트 */} +
+
+
+
+
+
+ + ))} +
+ ) +} diff --git a/src/features/retrospectives/components/index.ts b/src/features/retrospectives/components/index.ts new file mode 100644 index 0000000..91b716a --- /dev/null +++ b/src/features/retrospectives/components/index.ts @@ -0,0 +1,4 @@ +export { default as AiLoadingOverlay } from './AiLoadingOverlay' +export { default as AiSummaryToast } from './AiSummaryToast' +export { default as RetrospectiveCardButtons } from './RetrospectiveCardButtons' +export { default as RetrospectiveSummarySkeleton } from './RetrospectiveSummarySkeleton' diff --git a/src/features/retrospectives/index.ts b/src/features/retrospectives/index.ts new file mode 100644 index 0000000..4ffb8fb --- /dev/null +++ b/src/features/retrospectives/index.ts @@ -0,0 +1,2 @@ +// Components +export * from './components' diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx index 851429a..99ce3b3 100644 --- a/src/pages/Meetings/MeetingDetailPage.tsx +++ b/src/pages/Meetings/MeetingDetailPage.tsx @@ -8,6 +8,7 @@ import { MeetingDetailInfo, useMeetingDetail, } from '@/features/meetings' +import { RetrospectiveCardButtons } from '@/features/retrospectives' import type { GetConfirmedTopicsResponse, GetProposedTopicsResponse, @@ -115,6 +116,13 @@ export default function MeetingDetailPage() { {/* 약속 로딩 적용 */}
+ {meeting?.progressStatus === 'POST' && ( + + )} +

주제

() + + if (!gatheringId || !meetingId) return null + + return ( + <> + + + {/* 헤더: 타이틀 + 설명 + AI 요약 시작하기 버튼 */} +
+
+

약속 회고

+

+ 사전 의견과 녹음 파일을 분석하여 약속 회고를 자동 생성해요 +

+
+ +
+ + {/* 두 패널 영역 */} +
+ {/* 왼쪽: 수집된 사전 의견 */} +
+
+

수집된 사전 의견

+

{/* TODO: 사전 의견 카운트 */}0개

+
+ {/* TODO: 사전 의견 멤버 리스트 + 의견 내용 */} +
+ + {/* 오른쪽: 녹음 파일 업로드 */} +
+
+

녹음 파일 업로드

+

AI가 음성을 텍스트로 변환하여 분석해요

+
+ {/* TODO: 파일 업로드 드롭존 + 파일 리스트 테이블 */} +
+
+ + {/* TODO: AI 요약 중 모달 */} + + ) +} diff --git a/src/pages/Retrospectives/MeetingRetrospectivePage.tsx b/src/pages/Retrospectives/MeetingRetrospectivePage.tsx new file mode 100644 index 0000000..c9aac62 --- /dev/null +++ b/src/pages/Retrospectives/MeetingRetrospectivePage.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' + +import { + AiLoadingOverlay, + AiSummaryToast, + RetrospectiveSummarySkeleton, +} from '@/features/retrospectives' +import SubPageHeader from '@/shared/components/SubPageHeader' +import { ROUTES } from '@/shared/constants' +import { Button } from '@/shared/ui' + +type MeetingRetrospectiveLocationState = { + fromAiSummary?: boolean +} + +export default function MeetingRetrospectivePage() { + const navigate = useNavigate() + const location = useLocation() + const { gatheringId, meetingId } = useParams<{ + gatheringId: string + meetingId: string + }>() + + const fromAiSummary = + (location.state as MeetingRetrospectiveLocationState)?.fromAiSummary ?? false + + const [showOverlay, setShowOverlay] = useState(fromAiSummary) + const [showToast, setShowToast] = useState(false) + const [isLoading, setIsLoading] = useState(fromAiSummary) + + // 새로고침 시 오버레이 재표시 방지 + useEffect(() => { + if (fromAiSummary) { + window.history.replaceState({}, '') + } + }, [fromAiSummary]) + + // TODO: setTimeout mock → 실제 API 호출로 교체 + const handleSummaryComplete = useCallback(() => { + setShowOverlay(false) + setIsLoading(false) + setShowToast(true) + }, []) + + const handleCancelAiSummary = useCallback(() => { + setShowOverlay(false) + setIsLoading(false) + }, []) + + useEffect(() => { + if (!isLoading) return + + const timer = setTimeout(handleSummaryComplete, 3000) + return () => clearTimeout(timer) + }, [isLoading, handleSummaryComplete]) + + if (!gatheringId || !meetingId) return null + + return ( + <> + + + {/* 헤더: 약속 회고 타이틀 + 버튼 */} +
+

약속 회고

+
+ + +
+
+ + {/* 회고 콘텐츠 영역 */} +
+ {isLoading ? ( + + ) : ( + /* TODO: 실제 AI 요약 콘텐츠 렌더링 */ +

회고 콘텐츠가 여기에 표시됩니다.

+ )} +
+ + + setShowToast(false)} /> + + ) +} diff --git a/src/pages/Retrospectives/index.ts b/src/pages/Retrospectives/index.ts new file mode 100644 index 0000000..3db99f7 --- /dev/null +++ b/src/pages/Retrospectives/index.ts @@ -0,0 +1,2 @@ +export { default as MeetingRetrospectiveCreatePage } from './MeetingRetrospectiveCreatePage' +export { default as MeetingRetrospectivePage } from './MeetingRetrospectivePage' diff --git a/src/pages/index.ts b/src/pages/index.ts index 0313893..9b42c34 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -7,4 +7,5 @@ export * from './Landing' export * from './Meetings' export * from './PreOpinions' export * from './Records' +export * from './Retrospectives' export * from './Topics' diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 87b05fd..72a6d9d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -15,6 +15,8 @@ import { LoginPage, MeetingCreatePage, MeetingDetailPage, + MeetingRetrospectiveCreatePage, + MeetingRetrospectivePage, MeetingSettingPage, OnboardingPage, PreOpinionListPage, @@ -137,6 +139,14 @@ export const router = createBrowserRouter([ path: ROUTES.PRE_OPINIONS(':gatheringId', ':meetingId'), element: , }, + { + path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/retrospective`, + element: , + }, + { + path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId/retrospective/create`, + element: , + }, { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/setting`, element: , diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 01cbcb0..aad78dd 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -38,6 +38,12 @@ export const ROUTES = { TOPICS_CREATE: (gatheringId: number | string, meetingId: number | string) => `/gatherings/${gatheringId}/meetings/${meetingId}/topic-create`, + // Retrospectives (약속 회고) + MEETING_RETROSPECTIVE: (gatheringId: number | string, meetingId: number | string) => + `/gatherings/${gatheringId}/meetings/${meetingId}/retrospective`, + MEETING_RETROSPECTIVE_CREATE: (gatheringId: number | string, meetingId: number | string) => + `/gatherings/${gatheringId}/meetings/${meetingId}/retrospective/create`, + // Records RECORDS: '/records', RECORD_DETAIL: (id: number | string) => `/records/${id}`, diff --git a/src/shared/ui/Sonner.tsx b/src/shared/ui/Sonner.tsx index 8aac70b..c87cd6d 100644 --- a/src/shared/ui/Sonner.tsx +++ b/src/shared/ui/Sonner.tsx @@ -7,7 +7,7 @@ const Sonner = ({ ...props }: ToasterProps) => { gap={12} visibleToasts={3} offset={48} - icons={{ error: ' ' }} + icons={{ error: <> }} toastOptions={{ unstyled: true, classNames: {