diff --git a/src/feature/album/4cut/components/Capture4CutPortal.tsx b/src/feature/album/4cut/components/Capture4CutPortal.tsx index 8deff1da..5f98b13f 100644 --- a/src/feature/album/4cut/components/Capture4CutPortal.tsx +++ b/src/feature/album/4cut/components/Capture4CutPortal.tsx @@ -10,6 +10,7 @@ interface Capture4CutPortalProps { albumId: string; eventName?: string; eventDate?: string; + isFinalized?: boolean; } const Capture4CutPortal = ({ @@ -18,6 +19,7 @@ const Capture4CutPortal = ({ albumId, eventName, eventDate, + isFinalized = false, }: Capture4CutPortalProps) => (
diff --git a/src/feature/album/4cut/components/Container4Cut.tsx b/src/feature/album/4cut/components/Container4Cut.tsx index b3a71ca3..c5fcbab1 100644 --- a/src/feature/album/4cut/components/Container4Cut.tsx +++ b/src/feature/album/4cut/components/Container4Cut.tsx @@ -9,6 +9,7 @@ interface Container4CutProps { eventDate?: string; scale?: number; width?: number; + isFinalized?: boolean; } const BASE_WIDTH = 216; @@ -30,6 +31,7 @@ export default function Container4Cut({ eventName, scale = 1, width, + isFinalized = false, }: Container4CutProps) { // TODO : openapi type이 이상해서 임시 any처리. 백엔드랑 협의 필요 // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -64,7 +66,12 @@ export default function Container4Cut({ return (
void; +} + +const BASE_WIDTH = 216; +const BASE_HEIGHT = 384; +const BASE_ASPECT_RATIO = BASE_HEIGHT / BASE_WIDTH; + +export default function Container4CutExplanation({ + albumId, + eventName, + eventDate, + scale = 1, + width, + isFinalized = false, + onClose, +}: Container4CutExplanationProps) { + const calculatedWidth = width ?? BASE_WIDTH * scale; + const calculatedHeight = calculatedWidth * BASE_ASPECT_RATIO; + const { aiSummary, isLoading } = use4CutAiSummary(albumId); + + return ( +
+ {/* 헤더: 제목 + 날짜 + X 버튼 */} +
+
+

+ {eventName || '인생네컷'} +

+ {eventDate && ( +

+ {eventDate} +

+ )} +
+ {/* X 버튼 */} + +
+ + {/* Body: 텍스트 설명 */} +
+
+ {isLoading ? ( +
+
+ AI 요약 생성 중... +
+
+ ) : ( +

+ {aiSummary} +

+ )} +
+
+
+ ); +} diff --git a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx index ec43df54..1787d3cb 100644 --- a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx +++ b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx @@ -21,9 +21,11 @@ import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { useGetAlbumInfo } from '../../detail/hooks/useGetAlbumInfo'; +import { use4CutAiSummary } from '../hooks/use4CutAiSummary'; import { use4CutFixed } from '../hooks/use4CutFixed'; import { use4CutPreviewQuery } from '../hooks/use4CutPreviewQuery'; import Container4Cut from './Container4Cut'; +import Container4CutExplanation from './Container4CutExplanation'; const Capture4CutPortal = dynamic(() => import('./Capture4CutPortal'), { ssr: false, }); @@ -36,10 +38,12 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { const queryClient = useQueryClient(); const [isCaptureVisible, setIsCaptureVisible] = useState(false); const [isDownloading, setIsDownloading] = useState(false); + const [showExplanation, setShowExplanation] = useState(false); const captureRef = useRef(null); const { data } = useGetAlbumInfo(albumId); const { data: albumInformData } = useGetAlbumInform({ code: albumId }); const { data: { name } = {} } = useGetUserMe(); + const { isCompleted } = use4CutAiSummary(albumId); // TODO : openapi type이 이상해서 임시 any처리. 백엔드랑 협의 필요 const { @@ -98,6 +102,13 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { } }; + const handleFlipCard = () => { + if (!isCompleted && !showExplanation) { + return; + } + setShowExplanation(!showExplanation); + }; + const handleDownload = async () => { trackGaEvent(GA_EVENTS.click_download_4cut, { album_id: albumId, @@ -196,15 +207,64 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { {!isFinalized && (
현재 TOP 4 사진
)} -
- +
+
+ {/* 앞면 - 4컷 사진 */} +
+ +
+ {/* 뒷면 - 설명 */} + {isFinalized && ( +
+ +
+ )} +
{!is4CutPreviewPending && ( @@ -286,7 +346,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { )}{' '} {' '} {isDownloading && ( -
+
@@ -301,6 +361,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { albumId={albumId} eventName={data?.title} eventDate={data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''} + isFinalized={isFinalized} /> ); diff --git a/src/feature/album/4cut/hooks/use4CutAiSummary.ts b/src/feature/album/4cut/hooks/use4CutAiSummary.ts new file mode 100644 index 00000000..7f534966 --- /dev/null +++ b/src/feature/album/4cut/hooks/use4CutAiSummary.ts @@ -0,0 +1,34 @@ +import { ApiReturns, EP } from '@/global/api/ep'; +import { api } from '@/global/utils/api'; +import { useQuery } from '@tanstack/react-query'; + +const fetchAiSummary = async (albumId: string) => { + const response = await api.get({ + path: EP.cheese4cut.cheese4cutAiSummary(albumId), + }); + + return response.result; +}; + +export function use4CutAiSummary(albumId: string) { + const query = useQuery({ + queryKey: [EP.cheese4cut.cheese4cutAiSummary(albumId)], + queryFn: () => fetchAiSummary(albumId), + refetchInterval: (query) => { + // COMPLETED 상태면 polling 중단 + if (query.state.data?.status === 'COMPLETED') { + return false; + } + // 아니면 30초마다 polling + return 30000; + }, + refetchIntervalInBackground: false, + }); + + return { + ...query, + aiSummary: query.data?.content || '', + isCompleted: query.data?.status === 'COMPLETED', + title: query.data?.title || '', + }; +} diff --git a/src/feature/album/4cut/hooks/use4CutFixed.ts b/src/feature/album/4cut/hooks/use4CutFixed.ts index d5b6eeee..28766c98 100644 --- a/src/feature/album/4cut/hooks/use4CutFixed.ts +++ b/src/feature/album/4cut/hooks/use4CutFixed.ts @@ -8,8 +8,8 @@ interface Cheese4CutFixedProps { } const fetchData = async ({ albumId, photoIds }: Cheese4CutFixedProps) => { - const res = await api.post({ - path: EP.cheese4cut.finalize(albumId), + const res = await api.post({ + path: EP.cheese4cut.cheese4cutFixedAi(albumId), body: { photoIds }, }); return res.result; diff --git a/src/feature/album/detail/components/ScreenAlbumDetail.tsx b/src/feature/album/detail/components/ScreenAlbumDetail.tsx index 3512342c..1a693412 100644 --- a/src/feature/album/detail/components/ScreenAlbumDetail.tsx +++ b/src/feature/album/detail/components/ScreenAlbumDetail.tsx @@ -250,5 +250,6 @@ function mapLikedPhotosToPhotoList( isLiked: item.isLiked ?? false, isDownloaded: item.isDownloaded, isRecentlyDownloaded: item.isRecentlyDownloaded, + canDelete: false, })); } diff --git a/src/global/api/ep.ts b/src/global/api/ep.ts index b695f366..cbdac0af 100644 --- a/src/global/api/ep.ts +++ b/src/global/api/ep.ts @@ -45,7 +45,9 @@ export const EP = { "reportUploadResult": () => `/v1/photo/report`, }, cheese4cut: { + "cheese4cutAiSummary": (code: string | number) => `/v1/cheese4cut/${code}/ai-summary`, "finalize": (code: string | number) => `/v1/cheese4cut/${code}/fixed`, + "cheese4cutFixedAi": (code: string | number) => `/v1/cheese4cut/${code}/fixed/ai`, "preview": (code: string | number) => `/v1/cheese4cut/${code}/preview`, }, internal: { @@ -101,10 +103,12 @@ export interface Cheese4cutResponseSchema { "finalized"?: boolean; } export interface CommonResponseCheese4cutResponseSchema { "isSuccess"?: boolean; "code"?: number; "message"?: string; "result"?: Cheese4cutFinalResponseSchema | Cheese4cutPreviewResponseSchema; } export interface FinalPhotoInfoSchema { "photoId": number; "imageUrl": string; "photoRank": number; } export interface PreviewPhotoInfoSchema { "photoId": number; "imageUrl": string; "photoRank": number; } +export interface Cheese4cutAiResponseSchema { "status"?: string; "title"?: string; "content"?: string; } +export interface CommonResponseCheese4cutAiResponseSchema { "isSuccess"?: boolean; "code"?: number; "message"?: string; "result"?: Cheese4cutAiResponseSchema; } export interface AuthExchangeResponseSchema { "accessToken": string; "refreshToken": string; "isOnboarded": boolean; "userId": number; "name": string; "email": string; } export interface CommonResponseAuthExchangeResponseSchema { "isSuccess"?: boolean; "code"?: number; "message"?: string; "result"?: AuthExchangeResponseSchema; } export interface CommonResponsePhotoPageResponseSchema { "isSuccess"?: boolean; "code"?: number; "message"?: string; "result"?: PhotoPageResponseSchema; } -export interface PhotoListResponseSchema { "name"?: string; "photoId": number; "profileImage": string; "imageUrl"?: string; "thumbnailUrl": string; "likeCnt": number; "isLiked": boolean; "isDownloaded": boolean; "isRecentlyDownloaded": boolean; "canDelete"?: boolean; } +export interface PhotoListResponseSchema { "name"?: string; "photoId": number; "uploaderId"?: number; "profileImage": string; "imageUrl"?: string; "thumbnailUrl": string; "likeCnt": number; "isLiked": boolean; "isDownloaded": boolean; "isRecentlyDownloaded": boolean; "canDelete": boolean; } export interface PhotoPageResponseSchema { "responses": PhotoListResponseSchema[]; "listSize": number; "isFirst": boolean; "isLast": boolean; "hasNext": boolean; } export interface CommonResponsePhotoDetailResponseSchema { "isSuccess"?: boolean; "code"?: number; "message"?: string; "result"?: PhotoDetailResponseSchema; } export interface PhotoDetailResponseSchema { "name": string; "profileImage": string; "photoId": number; "imageUrl": string; "thumbnailUrl": string; "likesCnt": number; "isLiked": boolean; "isDownloaded": boolean; "isRecentlyDownloaded": boolean; "canDelete"?: boolean; "captureTime"?: string; "createdAt"?: string; } @@ -164,7 +168,9 @@ export type PhotoUnlikeResponse = CommonResponseVoidSchema["result"]; export type PhotoPresignedDownloadResponse = CommonResponsePhotoDownloadResponseSchema["result"]; export type PhotoPresignedUploadResponse = CommonResponsePhotoPresignedUrlResponseSchema["result"]; export type PhotoReportUploadResultResponse = CommonResponseVoidSchema["result"]; +export type Cheese4cutCheese4cutAiSummaryResponse = CommonResponseCheese4cutAiResponseSchema["result"]; export type Cheese4cutFinalizeResponse = CommonResponseVoidSchema["result"]; +export type Cheese4cutCheese4cutFixedAiResponse = CommonResponseVoidSchema["result"]; export type Cheese4cutPreviewResponse = CommonResponseCheese4cutResponseSchema["result"]; export type InternalThumbnailCompleteResponse = CommonResponseVoidSchema["result"]; @@ -201,7 +207,9 @@ export interface ApiReturns { "photo.presignedDownload": PhotoPresignedDownloadResponse; // POST /v1/photo/download-url "photo.presignedUpload": PhotoPresignedUploadResponse; // POST /v1/photo/presigned-url "photo.reportUploadResult": PhotoReportUploadResultResponse; // POST /v1/photo/report + "cheese4cut.cheese4cutAiSummary": Cheese4cutCheese4cutAiSummaryResponse; // GET /v1/cheese4cut/{code}/ai-summary "cheese4cut.finalize": Cheese4cutFinalizeResponse; // POST /v1/cheese4cut/{code}/fixed + "cheese4cut.cheese4cutFixedAi": Cheese4cutCheese4cutFixedAiResponse; // POST /v1/cheese4cut/{code}/fixed/ai "cheese4cut.preview": Cheese4cutPreviewResponse; // GET /v1/cheese4cut/{code}/preview "internal.thumbnailComplete": InternalThumbnailCompleteResponse; // POST /internal/thumbnail/complete }