diff --git a/frontend/package.json b/frontend/package.json index ef9d6fc0..4193f6c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.1.1", + "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.0", "@tanstack/react-query-devtools": "^5.85.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 05e581cf..5148d95d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hookform/resolvers': specifier: ^5.1.1 version: 5.1.1(react-hook-form@7.60.0(react@19.1.2)) + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@tailwindcss/vite': specifier: ^4.1.11 version: 4.1.11(vite@7.0.0(jiti@2.4.2)(lightningcss@1.30.1)) @@ -433,6 +436,9 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2483,6 +2489,8 @@ snapshots: '@kurkle/color@0.3.4': {} + '@microsoft/fetch-event-source@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0259284a..fb8e4235 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,6 @@ import { RouterProvider } from 'react-router-dom' import { router } from './router/router' import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { GlobalReportPoller } from './components/GlobalReportPoller' import { queryClient } from './utils/queryClient' import GlobalModal from './components/GlobalModal' @@ -11,11 +10,8 @@ function App() { <> - - {/* 전역 폴러 */} - ) diff --git a/frontend/src/api/report.ts b/frontend/src/api/report.ts index ebfca4cc..89b4f9b0 100644 --- a/frontend/src/api/report.ts +++ b/frontend/src/api/report.ts @@ -17,6 +17,7 @@ import type { ReportStatusDto, ResponseReportById, ResponseReportByUrl, + ResponseReportStatus, } from '../types/report/new' // URL로 리포트 분석 요청 @@ -60,7 +61,7 @@ export const getReportComments = async ({ reportId, type }: ReportCommentsDto): } // 리포트 분석 상태 조회 -export const getReportStatus = async ({ reportId }: ReportStatusDto) => { +export const getReportStatus = async ({ reportId }: ReportStatusDto): Promise => { const { data } = await axiosInstance.get(`/reports/${reportId}/status`) return data } diff --git a/frontend/src/components/GlobalProcessingModal.tsx b/frontend/src/components/GlobalProcessingModal.tsx new file mode 100644 index 00000000..c956ef36 --- /dev/null +++ b/frontend/src/components/GlobalProcessingModal.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { useReportStore, type ProcessingReport } from '../stores/reportStore' +import { useReportProgress, useReportStatus } from '../hooks/report' +import { useAuthStore } from '../stores/authStore' +import { useDeleteMyReport } from '../hooks/report/useDeleteMyReport' +import X from '../assets/icons/X.svg?react' +import { useToastStore } from '../stores/toastStore' + +interface ProcessingReportItemProps { + item: ProcessingReport +} + +export const ProcessingReportItem = ({ item }: ProcessingReportItemProps) => { + const navigate = useNavigate() + const removeReport = useReportStore((state) => state.removeReport) + const hideReport = useReportStore((state) => state.hideReport) + const showToast = useToastStore((state) => state.showToast) + + // 1. 상태 폴링 (완료/실패 여부 체크 & 서버 상태 동기화용) + const { isCompleted, isFailed, rawResult } = useReportStatus(item.reportId) + + // 2. SSE 연결 (실시간 진행률 수신) + const { currentStep } = useReportProgress( + item.reportId, + !isCompleted && !isFailed, // 완료나 실패가 아닐 때만 SSE 연결 + rawResult // 서버의 현재 상태 (재진입 시 동기화용) + ) + + const progressStyle = useMemo(() => { + switch (currentStep) { + case 1: + return { width: '25%', duration: '10000ms' } + case 2: + return { width: '50%', duration: '15000ms' } + case 3: + return { width: '90%', duration: '20000ms' } + case 4: + return { width: '100%', duration: '100ms' } + default: + return { width: '5%', duration: '0ms' } + } + }, [currentStep]) + + const channelId = useAuthStore((state) => state.channelId) + const { mutate: deleteReport } = useDeleteMyReport({ channelId: channelId || 0 }) + + // 실패 처리 + useEffect(() => { + if (isFailed) { + removeReport(item.reportId) + deleteReport({ reportId: item.reportId }) + } + }, [isFailed, removeReport, item.reportId, deleteReport]) + + const handleCloseProgress = (e: React.MouseEvent) => { + e.stopPropagation() + hideReport(item.reportId) + + showToast( + '리포트 생성이 백그라운드에서 계속됩니다.', + `리포트는 '저장소'에서 확인하실 수 있습니다.`, + 'default', + 6000 + ) + } + + const handleCloseComplete = (e: React.MouseEvent) => { + e.stopPropagation() + removeReport(item.reportId) + } + + const handleViewReport = () => { + navigate(`/report/${item.reportId}?video=${item.videoId}`) + removeReport(item.reportId) + } + + // ✅ CASE A: 완료된 경우 (완료 모달 표시) + if (isCompleted) { + return ( +
e.stopPropagation()} + className={` + relative flex flex-col mx-auto w-[calc(100%-16px)] tablet:w-[384px] desktop:w-[486px] + space-y-4 tablet:space-y-6 bg-surface-elevate-l2 p-6 rounded-3xl + `} + > + + +
+

+ 리포트 생성이 완료되었습니다. +

+ +
+ + +
+ ) + } + + // ✅ CASE B: 생성 중인데 사용자가 '닫기'를 누른 경우 + if (item.isHidden) { + return null + } + + // ✅ CASE C: 생성 중이고 화면에 보여야 하는 경우 (진행 모달) + return ( +
navigate(`/report/${item.reportId}?video=${item.videoId}`)} + className={` + relative flex flex-col mx-auto w-[calc(100%-16px)] tablet:w-[384px] desktop:w-[486px] + space-y-4 tablet:space-y-6 bg-surface-elevate-l2 p-6 rounded-3xl + `} + > + + +
+

+ {currentStep === 1 && '유튜브 데이터 수집 중..'} + {currentStep === 2 && '영상 지표 및 댓글 분석 중..'} + {currentStep === 3 && '이탈 구간과 알고리즘 최적화 분석 중..'} + {currentStep === 4 && '리포트 완성'} +

+ + + {/* 프로그레스 바 */} +
+
+
+
+ + +
+ ) +} + +export const GlobalProcessingModal = () => { + const { pathname } = useLocation() + const reports = useReportStore((state) => state.reports) + const isAuth = useAuthStore((state) => state.isAuth) + + if (!isAuth) return null + if (reports.length === 0) return null + + return ( +
+ {reports.map((report) => { + // 현재 보고 있는 리포트 페이지의 모달은 숨김 + const isCurrentPage = pathname.includes(`/report/${report.reportId}`) + if (isCurrentPage) return null + + return + })} +
+ ) +} diff --git a/frontend/src/components/GlobalReportPoller.tsx b/frontend/src/components/GlobalReportPoller.tsx deleted file mode 100644 index 06d1da18..00000000 --- a/frontend/src/components/GlobalReportPoller.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { usePollReportStatus } from '../hooks/report/usePollReportStatus' -import { useReportStore } from '../stores/reportStore' - -// 각 ID에 대한 폴링 작업을 수행하는 개별 워커 컴포넌트 -function PollWorker({ reportId }: { reportId: number }) { - // 훅을 호출하여 특정 ID에 대한 폴링을 시작합니다. - usePollReportStatus(reportId, { enabled: true }) - return null -} - -// 전역 폴러: pending 중인 모든 리포트를 감시합니다. -export function GlobalReportPoller() { - // 스토어에서 폴링이 필요한 모든 리포트 ID를 가져옵니다. - const pendingReportIds = useReportStore((state) => state.pendingReportIds) - - // 중복된 ID를 제거하여 각 ID에 대해 하나의 폴러만 실행되도록 보장합니다. - const uniquePendingReportIds = [...new Set(pendingReportIds)] - - return ( - <> - {/* 각 ID에 대해 개별적으로 폴링 워커를 실행합니다. */} - {uniquePendingReportIds.map((id) => ( - - ))} - - ) -} diff --git a/frontend/src/components/GlobalToast.tsx b/frontend/src/components/GlobalToast.tsx new file mode 100644 index 00000000..7846c480 --- /dev/null +++ b/frontend/src/components/GlobalToast.tsx @@ -0,0 +1,52 @@ +import { createPortal } from 'react-dom' +import * as motion from 'motion/react-client' +import { AnimatePresence } from 'motion/react' +import { useToastStore } from '../stores/toastStore' + +import ErrorIcon from '../assets/icons/error.svg?react' +import ToastBlur from '../assets/ellipses/toast.svg?react' + +export const GlobalToast = () => { + const { isVisible, title, description, type, hideToast } = useToastStore() + + const renderIcon = () => { + if (type === 'error') return + return + } + + return createPortal( + + {isVisible && ( +
+ + + +
+
+ {renderIcon()} +
+ +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+
+
+
+ )} +
, + document.body + ) +} diff --git a/frontend/src/constants/sse.ts b/frontend/src/constants/sse.ts new file mode 100644 index 00000000..3c228596 --- /dev/null +++ b/frontend/src/constants/sse.ts @@ -0,0 +1 @@ +export const SSE_URL = 'https://api.chaneling.com/sse/connect' diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 7d83a45f..416baf15 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1 +1,2 @@ export * from './useNavbarControls' +export * from './useIsMobile' diff --git a/frontend/src/hooks/report/index.ts b/frontend/src/hooks/report/index.ts index bcbac629..4f6c83ba 100644 --- a/frontend/src/hooks/report/index.ts +++ b/frontend/src/hooks/report/index.ts @@ -1 +1,4 @@ export * from './useGetDummyReport' +export * from './useReportStatus' +export * from './useReportProgress' +export * from './useGetVideoData' diff --git a/frontend/src/hooks/report/useGetVideoData.ts b/frontend/src/hooks/report/useGetVideoData.ts index 6a7a7c9d..931d3e26 100644 --- a/frontend/src/hooks/report/useGetVideoData.ts +++ b/frontend/src/hooks/report/useGetVideoData.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { getVideoData } from '../../api/report' -export default function useGetVideoData(videoId: number | undefined) { +export function useGetVideoData(videoId: number | undefined) { return useQuery({ queryKey: ['video', videoId], queryFn: async () => getVideoData({ videoId }), diff --git a/frontend/src/hooks/report/usePollReportStatus.ts b/frontend/src/hooks/report/usePollReportStatus.ts deleted file mode 100644 index e7bdcb56..00000000 --- a/frontend/src/hooks/report/usePollReportStatus.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import type { ReportStatus, ResponseReportStatus, Status } from '../../types/report/new' -import { getReportStatus } from '../../api/report' -import { useReportStore } from '../../stores/reportStore' -import { useEffect } from 'react' -import { useDeleteMyReport } from './useDeleteMyReport' -import { useAuthStore } from '../../stores/authStore' - -interface UseReportStatusOptions { - intervalMs?: number - enabled?: boolean -} - -// 폴링을 중단할 최종 상태 목록 -const TERMINAL_STATUSES: Status[] = ['COMPLETED', 'FAILED'] - -/** - * 모든 리포트 단계가 최종 상태에 도달했는지 확인하는 헬퍼 함수 - * @param status - 확인할 리포트 상태 객체 - * @returns 모든 단계가 최종 상태이면 true, 아니면 false - */ -export const areAllTasksTerminal = (status: ReportStatus): boolean => { - return ( - TERMINAL_STATUSES.includes(status.overviewStatus) && - TERMINAL_STATUSES.includes(status.analysisStatus) && - TERMINAL_STATUSES.includes(status.ideaStatus) - ) -} - -/** - * 리포트의 상세 상태를 주기적으로 폴링하는 커스텀 훅 - * 모든 하위 작업이 완료/실패 시 폴링을 자동으로 중지 - * @param reportId - 조회할 리포트의 ID (number 타입) - * @param options - 폴링 간격 등 추가 옵션 - */ -export const usePollReportStatus = (reportId: number | undefined, options: UseReportStatusOptions = {}) => { - const { intervalMs = 5_000, enabled = true } = options - - const channelId = useAuthStore((state) => state.user?.channelId) - const { updateReportStatus, removeReportStatus, removePendingReportId, beginReportCleanup } = useReportStore( - (state) => state.actions - ) - const cleanupReportIds = useReportStore((state) => state.cleanupReportIds) - const { mutate: deleteReport } = useDeleteMyReport({ channelId }) - - const query = useQuery({ - queryKey: ['reportStatus', reportId], - queryFn: () => { - if (typeof reportId !== 'number') { - throw new Error('Report ID must be a number.') - } - return getReportStatus({ reportId }) - }, - refetchInterval: (query) => { - const reportData = query.state.data?.result - if (reportData && areAllTasksTerminal(reportData)) { - return false - } - return intervalMs - }, - enabled: typeof reportId === 'number' && enabled, - retry: 0, - refetchOnWindowFocus: false, - select: (data) => data.result, - }) - - useEffect(() => { - if (query.isError && typeof reportId === 'number') { - removePendingReportId(reportId) - return - } - - if (query.data && typeof reportId === 'number') { - updateReportStatus(reportId, query.data) - - const isAnyFailed = Object.values(query.data).includes('FAILED') - if (isAnyFailed && !cleanupReportIds.includes(reportId)) { - beginReportCleanup(reportId) - removeReportStatus(reportId) - removePendingReportId(reportId) - deleteReport({ reportId }) - return - } - - if (areAllTasksTerminal(query.data)) { - removePendingReportId(reportId) - } - } - }, [ - query.data, - query.isError, - reportId, - updateReportStatus, - deleteReport, - removeReportStatus, - removePendingReportId, - beginReportCleanup, - cleanupReportIds, - ]) - - return query -} - -/** - * 리포트의 상태를 일회성으로 조회해 스토어에 업데이트하는 커스텀 훅 - * @param reportId - 조회할 리포트의 ID - * @returns isInvalidReportError - 초기 상태 조회 시 존재하지 않는 리포트 등의 에러 발생 여부 - */ -export const useGetInitialReportStatus = (reportId: number) => { - const currentReportStatus = useReportStore((state) => state.statuses[reportId]) - const updateReportStatus = useReportStore((state) => state.actions.updateReportStatus) - - const { data: initialStatusData, isError: isInvalidReportError } = useQuery({ - queryKey: ['reportStatus', reportId, 'initialCheck'], - queryFn: () => getReportStatus({ reportId }), - enabled: !!reportId && !currentReportStatus, - retry: false, - refetchOnWindowFocus: false, - select: (data) => data.result, - }) - - useEffect(() => { - if (initialStatusData) { - updateReportStatus(reportId, initialStatusData) - } - }, [initialStatusData, reportId, updateReportStatus]) - - return { isInvalidReportError } -} diff --git a/frontend/src/hooks/report/usePostReportById.ts b/frontend/src/hooks/report/usePostReportById.ts index 0fa0a2c9..fdc5a5d1 100644 --- a/frontend/src/hooks/report/usePostReportById.ts +++ b/frontend/src/hooks/report/usePostReportById.ts @@ -1,5 +1,4 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useReportStore } from '../../stores/reportStore' import type { ResponseReportById, ResultReportById } from '../../types/report/new' import type { AxiosError } from 'axios' import { postReportById } from '../../api/report' @@ -17,28 +16,19 @@ interface ReportByIdCallbacks { export default function usePostReportById({ onSuccess, onError }: ReportByIdCallbacks) { const { openModal } = useModalActions() const queryClient = useQueryClient() - const startGenerating = useReportStore((state) => state.actions.startGenerating) - const endGenerating = useReportStore((state) => state.actions.endGenerating) - const addPendingReportId = useReportStore((state) => state.actions.addPendingReportId) return useMutation({ mutationFn: postReportById, - onMutate: () => { - startGenerating() - }, onSuccess: (data: ResponseReportById) => { if (data.isSuccess && data.result) { queryClient.invalidateQueries({ queryKey: ['recommendedVideos'] }) - addPendingReportId(data.result.reportId) onSuccess(data.result) // 성공 콜백 호출 } else { - endGenerating() onError({ code: data.code, message: data.message }) } }, onError: (error: AxiosError) => { const state = error.response?.status - endGenerating() if (state === 403) { openModal('GENERATING_LIMIT') return diff --git a/frontend/src/hooks/report/usePostReportByUrl.ts b/frontend/src/hooks/report/usePostReportByUrl.ts index 2cce9f24..b9e25da7 100644 --- a/frontend/src/hooks/report/usePostReportByUrl.ts +++ b/frontend/src/hooks/report/usePostReportByUrl.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { postReportByUrl } from '../../api/report' -import { useReportStore } from '../../stores/reportStore' import type { ResponseReportByUrl, ResultReportByUrl } from '../../types/report/new' import type { AxiosError } from 'axios' import { useModalActions } from '../../stores/modalStore' @@ -17,27 +16,18 @@ interface ReportByUrlCallbacks { export default function usePostReportByUrl({ onSuccess, onError }: ReportByUrlCallbacks) { const { openModal } = useModalActions() const queryClient = useQueryClient() - const startGenerating = useReportStore((state) => state.actions.startGenerating) - const endGenerating = useReportStore((state) => state.actions.endGenerating) - const addPendingReportId = useReportStore((state) => state.actions.addPendingReportId) return useMutation({ mutationFn: postReportByUrl, - onMutate: () => { - startGenerating() - }, onSuccess: (data: ResponseReportByUrl) => { if (data.isSuccess && data.result) { queryClient.invalidateQueries({ queryKey: ['recommendedVideos'] }) - addPendingReportId(data.result.reportId) onSuccess(data.result) // 성공 콜백 호출 } else { - endGenerating() onError({ code: data.code, message: data.message }) } }, onError: (error: AxiosError) => { - endGenerating() const state = error.response?.status if (state === 403) { openModal('GENERATING_LIMIT') diff --git a/frontend/src/hooks/report/useReportProgress.ts b/frontend/src/hooks/report/useReportProgress.ts new file mode 100644 index 00000000..8b237a99 --- /dev/null +++ b/frontend/src/hooks/report/useReportProgress.ts @@ -0,0 +1,69 @@ +import { useEffect, useMemo } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { LOCAL_STORAGE_KEY } from '../../constants/key' +import { SSE_URL } from '../../constants/sse' + +interface ReportResult { + overviewStatus: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' + analysisStatus: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' +} + +export const useReportProgress = (targetReportId: number, enabled: boolean, initialResult?: ReportResult) => { + const queryClient = useQueryClient() + + const currentStep = useMemo(() => { + if (!initialResult) return 1 + + const { overviewStatus, analysisStatus } = initialResult + + const isOverviewCompleted = overviewStatus === 'COMPLETED' + const isAnalysisCompleted = analysisStatus === 'COMPLETED' + const isAllCompleted = isOverviewCompleted && isAnalysisCompleted + + if (isAllCompleted) return 4 + if (isOverviewCompleted || isAnalysisCompleted) return 3 + return 2 + }, [initialResult]) + + // SSE 연결 + useEffect(() => { + if (!enabled) return + + const tokenRaw = window.localStorage.getItem(LOCAL_STORAGE_KEY.accessToken) + const token = tokenRaw ? JSON.parse(tokenRaw) : null + + if (!token) return + + const ctrl = new AbortController() + + fetchEventSource(SSE_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'text/event-stream', + }, + signal: ctrl.signal, + + onmessage() { + queryClient.invalidateQueries({ + queryKey: ['reportStatus', targetReportId], + }) + }, + onerror(err) { + if (ctrl.signal.aborted) return + console.error('SSE Error:', err) + ctrl.abort() + throw err + }, + }).catch((err) => { + if (!ctrl.signal.aborted) console.error('FetchEventSource failed:', err) + }) + + return () => { + ctrl.abort() + } + }, [targetReportId, enabled, queryClient]) + + return { currentStep } +} diff --git a/frontend/src/hooks/report/useReportStatus.ts b/frontend/src/hooks/report/useReportStatus.ts new file mode 100644 index 00000000..798a4fb8 --- /dev/null +++ b/frontend/src/hooks/report/useReportStatus.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' +import { getReportStatus } from '../../api/report' +import { useMemo } from 'react' + +export const useReportStatus = (reportId: number) => { + const { + data: statusData, + isLoading, + isError, + } = useQuery({ + queryKey: ['reportStatus', reportId], + queryFn: () => getReportStatus({ reportId }), + staleTime: 0, + retry: false, + enabled: !!reportId, + refetchInterval: (query) => { + const data = query.state.data + + if (!data) return false + + const result = data?.result + if (!result) return false + + const { overviewStatus, analysisStatus } = result + + // 둘 다 완료되었거나, 하나라도 실패했으면 폴링 중단 + if ( + (overviewStatus === 'COMPLETED' && analysisStatus === 'COMPLETED') || + overviewStatus === 'FAILED' || + analysisStatus === 'FAILED' + ) { + return false + } + + // 그 외(진행 중)에는 3초마다 재요청 + return 3000 + }, + // 백그라운드(탭 전환 등)에 있어도 계속 폴링 + refetchIntervalInBackground: true, + }) + + const serverStatus = useMemo(() => { + if (isLoading) return 'LOADING' + if (isError) return 'FAILED' + + const result = statusData?.result + if (!result) return 'LOADING' + + const { overviewStatus, analysisStatus } = result + + if (overviewStatus === 'FAILED' || analysisStatus === 'FAILED') return 'FAILED' + if (overviewStatus === 'COMPLETED' && analysisStatus === 'COMPLETED') return 'COMPLETED' + + return 'PROCESSING' + }, [statusData, isLoading, isError]) + + return { + status: serverStatus, // 'LOADING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' + isProcessing: serverStatus === 'PROCESSING', + isCompleted: serverStatus === 'COMPLETED', + isFailed: serverStatus === 'FAILED', + isLoading: serverStatus === 'LOADING', + rawResult: statusData?.result, + } +} diff --git a/frontend/src/hooks/main/useIsMobile.ts b/frontend/src/hooks/useIsMobile.ts similarity index 86% rename from frontend/src/hooks/main/useIsMobile.ts rename to frontend/src/hooks/useIsMobile.ts index 966bd0c4..b5e115d7 100644 --- a/frontend/src/hooks/main/useIsMobile.ts +++ b/frontend/src/hooks/useIsMobile.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -export default function useIsMobile(breakpoint = 768) { +export function useIsMobile(breakpoint = 768) { const [isMobile, setIsMobile] = useState(false) useEffect(() => { diff --git a/frontend/src/layouts/RootLayout.tsx b/frontend/src/layouts/RootLayout.tsx index d074dbad..25c27404 100644 --- a/frontend/src/layouts/RootLayout.tsx +++ b/frontend/src/layouts/RootLayout.tsx @@ -1,8 +1,6 @@ import { Outlet, useLocation } from 'react-router-dom' import { NavbarWrapper } from './_components/navbar/NavbarWrapper' -import LoadingSpinner from '../components/LoadingSpinner' import ScrollToTop from '../components/ScrollToTop' -import { useReportStore } from '../stores/reportStore' import { useEffect } from 'react' import { useLocalStorage } from '../hooks/useLocalStorage' import { LOCAL_STORAGE_KEY } from '../constants/key' @@ -10,14 +8,14 @@ import { useFetchAndSetUser } from '../hooks/channel/useFetchAndSetUser' import { NavbarModalsContainer } from '../pages/auth' import { SettingModalContainer } from '../pages/setting/_components/SettingModalContainer' import AuthWatcher from '../components/AuthWatcher' +import { GlobalProcessingModal } from '../components/GlobalProcessingModal' +import { GlobalToast } from '../components/GlobalToast' import { GoogleAnalytics } from '../components/GoogleAnalytics' export default function RootLayout() { const location = useLocation() const isMain = location.pathname === '/' - const isReportGenerating = useReportStore((state) => state.isReportGenerating) - const { getItem: getChannelId, removeItem: removeChannelId } = useLocalStorage(LOCAL_STORAGE_KEY.channelId) const { getItem: getIsNew, removeItem: removeIsNew } = useLocalStorage(LOCAL_STORAGE_KEY.isNew) const { fetchAndSetUser } = useFetchAndSetUser() @@ -38,6 +36,8 @@ export default function RootLayout() { + +
- -
) diff --git a/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx b/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx index e8d10cfc..1df6360d 100644 --- a/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx +++ b/frontend/src/layouts/_components/navbar/NavbarLinksList.tsx @@ -3,8 +3,8 @@ import { NavbarLink, NavbarModalButton } from './NavbarLink' import { FEEDBACK_LINK, LOGIN_LINK, NAVIGATE_LINKS, PLUS_LINK } from './navbarLinks' import { NavbarUserInfo } from './NavbarUserInfo' import { useAuthStore } from '../../../stores/authStore' -import useIsMobile from '../../../hooks/main/useIsMobile' import Settings from '../../../assets/icons/settings.svg?react' +import { useIsMobile } from '../../../hooks' interface NavbarLinksListProps { loginButtonRef?: React.RefObject diff --git a/frontend/src/pages/main/_components/UrlInputForm.tsx b/frontend/src/pages/main/_components/UrlInputForm.tsx index b7775729..de1b8633 100644 --- a/frontend/src/pages/main/_components/UrlInputForm.tsx +++ b/frontend/src/pages/main/_components/UrlInputForm.tsx @@ -5,7 +5,7 @@ import ErrorIcon from '../../../assets/icons/error.svg?react' import { useUrlInput } from '../../../hooks/main/useUrlInput' import ArrowButton from '../../../components/ArrowButton' import { ErrorToast } from './ErrorToast' -import useGetVideoData from '../../../hooks/report/useGetVideoData' +import { useGetVideoData } from '../../../hooks/report' import { trackEvent } from '../../../utils/analytics' export const UrlInputForm = () => { @@ -51,8 +51,9 @@ export const UrlInputForm = () => { )} > { >
state.actions.endGenerating) - const currentReportStatus = useReportStore((state) => state.statuses[reportId]) - const pendingReportIds = useReportStore((state) => state.pendingReportIds) + const addReport = useReportStore((state) => state.addReport) + const removeReport = useReportStore((state) => state.removeReport) + const addCompletedReport = useReportStore((state) => state.addCompletedReport) - const { isInvalidReportError } = useGetInitialReportStatus(reportId) + // 영상 정보 조회: VideoSummary에 전달 + const { data: videoData, isPending: isVideoLoading } = useGetVideoData(videoId) + const normalizedVideoData: NormalizedVideoData | undefined = useMemo(() => { + return videoData ? adaptVideoMeta(videoData, false) : undefined + }, [videoData]) - // ✅ 페이지 진입 시 해당 리포트 ID로 상태가 없을 때만 일회성으로 서버에 상태 조회 - const needsPolling = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) - usePollReportStatus(reportId, { enabled: needsPolling }) + // 리포트 생성 상태 조회 + // 리포트의 생성 여부 분리(SSE, 리포트 조회)를 위함 + const { rawResult, isProcessing, isFailed } = useReportStatus(reportId) - // ✅ 해당 리포트 ID가 PENDING 중일 경우 로컬 폴링 - const isKnownToHaveFailed = useMemo(() => { - if (!currentReportStatus) return false - const { overviewStatus, analysisStatus } = currentReportStatus - return overviewStatus === 'FAILED' || analysisStatus === 'FAILED' - }, [currentReportStatus]) + const channelId = useAuthStore((state) => state.channelId) + const { mutate: deleteReport } = useDeleteMyReport({ channelId: channelId || 0 }) - const isInvalidOrDeleted = isInvalidReportError - const shouldShowError = isKnownToHaveFailed || isInvalidOrDeleted + // SSE 훅 연동 + const { currentStep } = useReportProgress(reportId, isProcessing, rawResult) - // ✅ 리포트가 생성 중인 경우 - const isGenerating = useMemo(() => pendingReportIds.includes(reportId), [pendingReportIds, reportId]) - - const handleCloseErrorModal = () => navigate('/', { replace: true }) + const hasSeenCompletionRef = useRef(false) + const latestResultRef = useRef(null) + // 탭 구성 const TABS = useMemo( () => [ { index: 0, label: '개요', component: }, @@ -60,25 +66,15 @@ export default function ReportPage() { const [activeTab, setActiveTab] = useState(TABS[0]) const [isOpenUpdateModal, setIsOpenUpdateModal] = useState(false) - const { data: videoData, isPending } = useGetVideoData(videoId) - const normalizedVideoData: NormalizedVideoData | undefined = videoData - ? adaptVideoMeta(videoData, false) - : undefined - useEffect(() => { - if (!isPending) endGenerating() - }, [isPending, endGenerating]) - - // 영상 정보 조회가 성공하면 로딩 스피너를 종료 - useEffect(() => { - if (!isPending && videoData) { + if (!isVideoLoading && videoData) { trackEvent({ category: 'Report', action: 'View Report', label: 'Real Report', }) } - }, [isPending, videoData]) + }, [isVideoLoading, videoData]) const handleTabChange = (tab: (typeof TABS)[number]) => { if (tab.index === activeTab.index) return @@ -107,18 +103,118 @@ export default function ReportPage() { } const handleResetTab = () => setActiveTab(TABS[0]) + const handleCloseErrorModal = () => { + deleteReport({ reportId }) + navigate('/', { replace: true }) + } + + const [displayStep, setDisplayStep] = useState(null) + const firstVisibleRef = useRef(false) + + useEffect(() => { + if (!isProcessing) { + setDisplayStep(null) + firstVisibleRef.current = false + return + } + + if (!firstVisibleRef.current) { + firstVisibleRef.current = true + setDisplayStep(1) + + const timer = setTimeout(() => { + setDisplayStep(currentStep) + }, 500) // 최소 노출 시간 + + return () => clearTimeout(timer) + } + + setDisplayStep(currentStep) + }, [isProcessing, currentStep]) + + // 리포트 생성 상태 동기화 + useEffect(() => { + if (isProcessing && normalizedVideoData) { + addReport({ + reportId, + videoId, + title: normalizedVideoData.videoTitle, + }) + } + }, [isProcessing, reportId, videoId, normalizedVideoData, addReport]) + + useEffect(() => { + if (!isProcessing && rawResult) { + const { overviewStatus, analysisStatus } = rawResult + + if (overviewStatus === 'COMPLETED' && analysisStatus === 'COMPLETED') { + hasSeenCompletionRef.current = true + } + } + }, [isProcessing, rawResult]) + + // 항상 최신 rawResult 저장 + useEffect(() => { + latestResultRef.current = rawResult + }, [rawResult]) + + // 완료를 직접 본 경우 처리 + useEffect(() => { + if (!rawResult) return + + const { overviewStatus, analysisStatus } = rawResult + const isCompleted = overviewStatus === 'COMPLETED' && analysisStatus === 'COMPLETED' + + if (!isCompleted) return + + // 이 페이지에서 완료를 직접 본 경우 + if (!isProcessing) { + hasSeenCompletionRef.current = true + removeReport(reportId) + } + }, [rawResult, isProcessing, reportId, removeReport]) + + // 진짜 unmount 시에만 실행 + useEffect(() => { + return () => { + const result = latestResultRef.current + if (!result) return + + const { overviewStatus, analysisStatus } = result + + const isCompleted = overviewStatus === 'COMPLETED' && analysisStatus === 'COMPLETED' + + // 내가 완료를 직접 보지 않았고, + // 완료된 상태라면 → background 완료 처리 + if (isCompleted && !hasSeenCompletionRef.current) { + addCompletedReport(reportId) + removeReport(reportId) + } + } + // 의존성 비움 (unmount 전용) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (isFailed) { + return + } return (
- {normalizedVideoData && ( - - )} + {/* 프로그레스 바 */} + {isProcessing && displayStep !== null && } + {/* 리포트 콘텐츠 */}
- {isPending ? : } + {isVideoLoading || !normalizedVideoData ? ( + + ) : ( + + )}
+ {/* 업데이트 모달 */} {isOpenUpdateModal && ( - - - - {/* 우선순위에 따른 모달 렌더링 */} - {shouldShowError ? ( - // 1순위: 생성 실패 에러 모달 - - ) : isGenerating ? ( - // 2순위: 생성 중 모달 - - ) : null} + {!isProcessing && } + +
) } diff --git a/frontend/src/pages/report/_components/ProgressBar.tsx b/frontend/src/pages/report/_components/ProgressBar.tsx new file mode 100644 index 00000000..c41aa352 --- /dev/null +++ b/frontend/src/pages/report/_components/ProgressBar.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx' +import { useIsMobile } from '../../../hooks' + +const STEPS = [ + { id: 1, label: '유튜브\n데이터 수집', width: 'w-[6rem]', shortWidth: 'w-[3.5rem]' }, + { id: 2, label: '영상 지표 및\n댓글 분석', width: 'w-[9.375rem]', shortWidth: 'w-[4.688rem]' }, + { id: 3, label: '이탈 구간과\n알고리즘 최적화 분석', width: 'w-[16.375rem]', shortWidth: 'w-[8.188rem]' }, + { id: 4, label: '리포트\n완성', width: 'w-[3rem]', shortWidth: 'w-[1.5rem]' }, +] + +interface ProgressBarProps { + currentStep: number +} + +export const ProgressBar = ({ currentStep }: ProgressBarProps) => { + const isMobile = useIsMobile() + + return ( +
+
+ {STEPS.map((step) => { + const isCompleted = currentStep > step.id + const isCurrent = currentStep === step.id + const isActive = currentStep >= step.id + + return ( +
+ {/* label */} +

+ {step.label} +

+ {/* indicator */} +
+
+
+ {/* bar */} +
+
+
+
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/pages/report/_components/RefreshButton.tsx b/frontend/src/pages/report/_components/RefreshButton.tsx new file mode 100644 index 00000000..bfaed80e --- /dev/null +++ b/frontend/src/pages/report/_components/RefreshButton.tsx @@ -0,0 +1,20 @@ +import Refresh from '../../../assets/icons/refresh_2.svg?react' + +interface RefreshButtonProps { + handleClick: () => void +} + +export const RefreshButton = ({ handleClick }: RefreshButtonProps) => { + return ( + + ) +} diff --git a/frontend/src/pages/report/_components/UpdateModal.tsx b/frontend/src/pages/report/_components/UpdateModal.tsx index 5311bd2e..c9399824 100644 --- a/frontend/src/pages/report/_components/UpdateModal.tsx +++ b/frontend/src/pages/report/_components/UpdateModal.tsx @@ -1,7 +1,6 @@ import { useNavigate } from 'react-router-dom' import Modal from '../../../components/Modal' import usePostReportById from '../../../hooks/report/usePostReportById' -import { useReportStore } from '../../../stores/reportStore' import { useQueryClient } from '@tanstack/react-query' import { useAuthStore } from '../../../stores/authStore' import { trackEvent } from '../../../utils/analytics' @@ -19,11 +18,8 @@ export const UpdateModal = ({ videoId, reportId, handleModalClick, handleResetTa const user = useAuthStore((state) => state.user) const channelId = user?.channelId - const addPendingReportId = useReportStore((state) => state.actions.addPendingReportId) - const { mutate: requestNewReport } = usePostReportById({ onSuccess: ({ reportId: newReportId }) => { - addPendingReportId(newReportId) if (typeof channelId === 'number') { queryClient.invalidateQueries({ queryKey: ['my', 'report', channelId], diff --git a/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx b/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx index 65e42ff3..128e6d60 100644 --- a/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx +++ b/frontend/src/pages/report/_components/analysis/TabAnalysis.tsx @@ -3,7 +3,7 @@ import { ViewerExitAnalysis } from './ViewerExitAnalysis' import { Skeleton } from './Skeleton' import useGetReportAnalysis from '../../../../hooks/report/useGetReportAnalysis' import { useGetDummyAnalysis } from '../../../../hooks/report/useGetDummyReport' -import { useReportStore } from '../../../../stores/reportStore' +import { useReportStatus } from '../../../../hooks/report' import { trackEvent } from '../../../../utils/analytics' import { useScrollTracking } from '../../../../hooks/useScrollTracking' @@ -13,8 +13,8 @@ interface TabAnalysisProps { } export const TabAnalysis = ({ reportId, isDummy = false }: TabAnalysisProps) => { - const analysisStatus = useReportStore((state) => state.statuses[reportId]?.analysisStatus) - const isCompleted = analysisStatus === 'COMPLETED' + const { rawResult, isLoading: isStatusLoading } = useReportStatus(reportId) + const isCompleted = rawResult?.analysisStatus === 'COMPLETED' const { data: realData, isLoading: isRealLoading } = useGetReportAnalysis({ reportId, @@ -27,7 +27,7 @@ export const TabAnalysis = ({ reportId, isDummy = false }: TabAnalysisProps) => }) const analysisData = isDummy ? dummyData : realData - const isLoading = isDummy ? isDummyLoading : !isCompleted || isRealLoading + const isLoading = isDummy ? isDummyLoading : isStatusLoading || !isCompleted || isRealLoading useScrollTracking({ containerId: 'scroll-container', diff --git a/frontend/src/pages/report/_components/index.ts b/frontend/src/pages/report/_components/index.ts index 82473482..b3acf7c2 100644 --- a/frontend/src/pages/report/_components/index.ts +++ b/frontend/src/pages/report/_components/index.ts @@ -2,8 +2,12 @@ export * from './overview/TabOverview' export * from './analysis/TabAnalysis' export * from './VideoSummary' +export * from './VideoSummarySkeleton' + export * from './Tag' export * from '../../../components/TitledSection' export * from './GuestModal' export * from './UpdateModal' export * from './GenerateErrorModal' +export * from './ProgressBar' +export * from './RefreshButton' diff --git a/frontend/src/pages/report/_components/overview/TabOverview.tsx b/frontend/src/pages/report/_components/overview/TabOverview.tsx index b484ee6c..46e411e6 100644 --- a/frontend/src/pages/report/_components/overview/TabOverview.tsx +++ b/frontend/src/pages/report/_components/overview/TabOverview.tsx @@ -7,8 +7,7 @@ import { Skeleton } from './Skeleton' import useGetReportOverview from '../../../../hooks/report/useGetReportOverview' import type { OverviewDataProps } from '../../../../types/report/all' -import { useReportStore } from '../../../../stores/reportStore' -import { useGetDummyOverview } from '../../../../hooks/report' +import { useGetDummyOverview, useReportStatus } from '../../../../hooks/report' interface TabOverviewProps { reportId: number @@ -16,8 +15,8 @@ interface TabOverviewProps { } export const TabOverview = ({ reportId, isDummy = false }: TabOverviewProps) => { - const overviewStatus = useReportStore((state) => state.statuses[reportId]?.overviewStatus) - const isCompleted = overviewStatus === 'COMPLETED' + const { rawResult, isLoading: isStatusLoading } = useReportStatus(reportId) + const isCompleted = rawResult?.overviewStatus === 'COMPLETED' const { data: realData, isLoading: isRealLoading } = useGetReportOverview({ reportId, @@ -30,7 +29,7 @@ export const TabOverview = ({ reportId, isDummy = false }: TabOverviewProps) => }) const overviewData = isDummy ? dummyData : realData - const isLoading = isDummy ? isDummyLoading : !isCompleted || isRealLoading + const isLoading = isDummy ? isDummyLoading : isStatusLoading || !isCompleted || isRealLoading const shouldShowUpdateSummary = !isDummy && !!overviewData?.updateSummary?.trim() if (isLoading || !overviewData) return diff --git a/frontend/src/stores/reportStore.ts b/frontend/src/stores/reportStore.ts index 09f56300..e10a0b17 100644 --- a/frontend/src/stores/reportStore.ts +++ b/frontend/src/stores/reportStore.ts @@ -1,81 +1,65 @@ import { create } from 'zustand' -import { createJSONStorage, devtools, persist } from 'zustand/middleware' -import type { ReportStatus, Statuses } from '../types/report/new' +import { persist } from 'zustand/middleware' -interface ReportActions { - startGenerating: () => void - endGenerating: () => void - updateReportStatus: (reportId: number, partialStatus: Partial) => void - removeReportStatus: (reportId: number) => void - addPendingReportId: (reportId: number) => void - removePendingReportId: (reportId: number) => void - beginReportCleanup: (reportId: number) => void +export interface ProcessingReport { + reportId: number + videoId: number + title: string + isHidden: boolean } interface ReportState { - isReportGenerating: boolean + reports: ProcessingReport[] + completedReports: number[] - /** - * 각 리포트 ID를 키로 하여 ReportStatus 객체를 저장 - * ex) { 17: { overviewStatus: 'PENDING', analysisStatus: 'PENDING', ... } } - */ - statuses: Record - /** - * 현재 폴링이 필요한 리포트 ID 목록 - */ - pendingReportIds: number[] - cleanupReportIds: number[] - actions: ReportActions + addReport: (report: Omit) => void + removeReport: (reportId: number) => void + hideReport: (reportId: number) => void + addCompletedReport: (reportId: number) => void + removeCompletedReport: (reportId: number) => void + clearReports: () => void } export const useReportStore = create()( - devtools( - persist( - (set) => ({ - isReportGenerating: false, - statuses: {}, - pendingReportIds: [], - cleanupReportIds: [], - actions: { - startGenerating: () => set({ isReportGenerating: true }), - endGenerating: () => set({ isReportGenerating: false }), - updateReportStatus: (reportId, partialStatus) => - set((state) => ({ - statuses: { - ...state.statuses, - [reportId]: { - ...state.statuses[reportId], - ...partialStatus, - }, - }, - })), - removeReportStatus: (reportId) => - set((state) => { - const newStatuses = { ...state.statuses } - delete newStatuses[reportId] - return { statuses: newStatuses } - }), - addPendingReportId: (reportId) => - set((state) => ({ - pendingReportIds: [...state.pendingReportIds, reportId], - })), - removePendingReportId: (reportId) => - set((state) => ({ - pendingReportIds: state.pendingReportIds.filter( - (pendingId) => pendingId !== reportId - ), - })), - beginReportCleanup: (reportId) => - set((state) => ({ - cleanupReportIds: [...state.cleanupReportIds, reportId], - })), - }, - }), - { - name: 'report-storage', - storage: createJSONStorage(() => sessionStorage), - partialize: (state) => ({ pendingReportIds: state.pendingReportIds }), - } - ) + persist( + (set) => ({ + reports: [], + completedReports: [], + + addReport: (newReport) => + set((state) => { + // 이미 있으면 무시 + if (state.reports.some((r) => r.reportId === newReport.reportId)) { + return state + } + return { reports: [...state.reports, { ...newReport, isHidden: false }] } + }), + + removeReport: (reportId) => + set((state) => ({ + reports: state.reports.filter((r) => r.reportId !== reportId), + })), + + // 리포트를 삭제하지 않고 숨김 처리만 함 (백그라운드 실행) + hideReport: (reportId) => + set((state) => ({ + reports: state.reports.map((r) => (r.reportId === reportId ? { ...r, isHidden: true } : r)), + })), + + addCompletedReport: (reportId) => + set((state) => ({ + completedReports: [...state.completedReports, reportId], + })), + + removeCompletedReport: (reportId) => + set((state) => ({ + completedReports: state.completedReports.filter((id) => id !== reportId), + })), + + clearReports: () => set({ reports: [] }), + }), + { + name: 'processing-reports-storage', + } ) ) diff --git a/frontend/src/stores/toastStore.ts b/frontend/src/stores/toastStore.ts new file mode 100644 index 00000000..cc143941 --- /dev/null +++ b/frontend/src/stores/toastStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' + +type ToastType = 'success' | 'error' | 'default' + +interface ToastState { + isVisible: boolean + title: string | null + description: string | null + type: ToastType + + showToast: (title: string, description: string, type?: ToastType, duration?: number) => void + hideToast: () => void +} + +let toastTimeout: ReturnType + +export const useToastStore = create((set) => ({ + isVisible: false, + title: null, + description: null, + type: 'default', + + showToast: (title, description, type = 'default', duration = 3000) => { + set({ isVisible: true, title, description, type }) + + if (toastTimeout) clearTimeout(toastTimeout) + + toastTimeout = setTimeout(() => { + set({ isVisible: false }) + }, duration) + }, + + hideToast: () => set({ isVisible: false }), +})) diff --git a/frontend/src/styles/animation.css b/frontend/src/styles/animation.css new file mode 100644 index 00000000..a996cee4 --- /dev/null +++ b/frontend/src/styles/animation.css @@ -0,0 +1,50 @@ +@layer utilities { + .multi-line-ellipsis { + @apply overflow-hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .page-transition { + animation: fadeIn 0.1s ease-in; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .modal-animation { + animation: fadeInScaleUp 0.3s ease-in-out; + } + + @keyframes fadeInScaleUp { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .shadow-pulse { + animation: shadowPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + + @keyframes shadowPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(250, 77, 86, 0); + } + 50% { + box-shadow: 0 0 8px 4px rgba(250, 77, 86, 0.5); + } + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e26345a9..72a697f1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2,6 +2,7 @@ @plugin '@tailwindcss/typography'; @import './scrollbar.css'; @import './typo.css'; +@import './animation.css'; @font-face { font-family: 'Pretendard'; @@ -118,40 +119,3 @@ @apply text-[14px] leading-[140%] font-normal tracking-[-0.35px]; } } - -@layer utilities { - .multi-line-ellipsis { - @apply overflow-hidden; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - - .page-transition { - animation: fadeIn 0.1s ease-in; - } - - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - .modal-animation { - animation: fadeInScaleUp 0.3s ease-in-out; - } - - @keyframes fadeInScaleUp { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } - } -} diff --git a/frontend/src/types/report/new.ts b/frontend/src/types/report/new.ts index 099e92f9..fc037491 100644 --- a/frontend/src/types/report/new.ts +++ b/frontend/src/types/report/new.ts @@ -34,11 +34,10 @@ export type Status = 'PENDING' | 'COMPLETED' | 'FAILED' export type Statuses = { overviewStatus: Status analysisStatus: Status - ideaStatus: Status + ideaStatus?: Status } export type ReportStatus = { - taskId: number reportId: number } & Statuses diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index fd2b5090..65dd7b8e 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -1,6 +1,7 @@ import { axiosInstance } from '../api/axios' import { LOCAL_STORAGE_KEY } from '../constants/key' import { useAuthStore } from '../stores/authStore' +import { useReportStore } from '../stores/reportStore' import { useSNSFormStore } from '../stores/snsFormStore' import { queryClient } from './queryClient' @@ -20,6 +21,8 @@ export async function logoutCore() { const { resetFormData, setOwner } = useSNSFormStore.getState() resetFormData() setOwner(null) + + useReportStore.getState().clearReports?.() } catch (e) { console.error('auth 상태 초기화 실패:', e) } @@ -27,6 +30,7 @@ export async function logoutCore() { try { useAuthStore.persist?.clearStorage?.() useSNSFormStore.persist?.clearStorage?.() + useReportStore.persist?.clearStorage?.() } catch (e) { console.error('persist clear 실패: ', e) }