From f2a45b455d276f137e7e7aa895e615feda11467f Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 00:06:39 +0900 Subject: [PATCH 1/7] feat: Add 4-cut photo album feature including display, explanation, and capture components. --- .../4cut/components/Capture4CutPortal.tsx | 3 + .../album/4cut/components/Container4Cut.tsx | 9 +- .../components/Container4CutExplanation.tsx | 92 +++++++++++++++++++ .../album/4cut/components/ScreenAlbum4Cut.tsx | 75 +++++++++++++-- 4 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 src/feature/album/4cut/components/Container4CutExplanation.tsx 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({ + eventName, + eventDate, + scale = 1, + width, + isFinalized = false, + onClose, +}: Container4CutExplanationProps) { + const calculatedWidth = width ?? BASE_WIDTH * scale; + const calculatedHeight = calculatedWidth * BASE_ASPECT_RATIO; + + return ( +
+ {/* 헤더: 제목 + 날짜 + X 버튼 */} +
+
+

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

+ {eventDate && ( +

{eventDate}

+ )} +
+ {/* X 버튼 */} + +
+ + {/* Body: 텍스트 설명 */} +
+
+
+

+ 여러분의 소중한 순간을 4장의 사진으로 담아내는 특별한 기능입니다. +

+ +
+

+ ✨ 특징 +

+
    +
  • • 앨범의 베스트 사진 4장 자동 선정
  • +
  • • 인생네컷 스타일 레이아웃
  • +
  • • 다운로드 및 공유 가능
  • +
+
+ +
+

+ 💡 사용 방법 +

+

+ 메이커가 사진을 확정하면 자동으로 생성됩니다. 다운로드 버튼을 + 눌러 저장하세요! +

+
+
+
+
+
+ ); +} diff --git a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx index ec43df54..da5d059e 100644 --- a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx +++ b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx @@ -24,6 +24,7 @@ import { useGetAlbumInfo } from '../../detail/hooks/useGetAlbumInfo'; 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,6 +37,7 @@ 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 }); @@ -98,6 +100,10 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { } }; + const handleFlipCard = () => { + setShowExplanation(!showExplanation); + }; + const handleDownload = async () => { trackGaEvent(GA_EVENTS.click_download_4cut, { album_id: albumId, @@ -196,15 +202,63 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { {!isFinalized && (
현재 TOP 4 사진
)} -
- +
+
+ {/* 앞면 - 4컷 사진 */} +
+ +
+ {/* 뒷면 - 설명 */} + {isFinalized && ( +
+ +
+ )} +
{!is4CutPreviewPending && ( @@ -286,7 +340,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { )}{' '} {' '} {isDownloading && ( -
+
@@ -301,6 +355,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { albumId={albumId} eventName={data?.title} eventDate={data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''} + isFinalized={isFinalized} /> ); From 5dfdb0e861ff76c8c45a1bbd68275d9c317c6575 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 10:17:50 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20cheese4cut=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20-=20'fixed'=EC=97=90=EC=84=9C?= =?UTF-8?q?=20'fixed/ai'=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/global/api/ep.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/global/api/ep.ts b/src/global/api/ep.ts index b695f366..727f6d33 100644 --- a/src/global/api/ep.ts +++ b/src/global/api/ep.ts @@ -45,7 +45,7 @@ export const EP = { "reportUploadResult": () => `/v1/photo/report`, }, cheese4cut: { - "finalize": (code: string | number) => `/v1/cheese4cut/${code}/fixed`, + "finalize": (code: string | number) => `/v1/cheese4cut/${code}/fixed/ai`, "preview": (code: string | number) => `/v1/cheese4cut/${code}/preview`, }, internal: { From e130623a3a9ff824841165c746177b8ddcc0c4e7 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 14:17:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20cheese4cut=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/feature/album/4cut/hooks/use4CutFixed.ts | 4 ++-- src/global/api/ep.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) 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/global/api/ep.ts b/src/global/api/ep.ts index 727f6d33..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: { - "finalize": (code: string | number) => `/v1/cheese4cut/${code}/fixed/ai`, + "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 } From a514e9deb0e130d01a323a5fd4bbd33ef2e3fa17 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 15:18:22 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=204=EC=BB=B7=20AI=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Container4CutExplanation.tsx | 43 ++++++++----------- .../album/4cut/components/ScreenAlbum4Cut.tsx | 1 + .../album/4cut/hooks/use4CutAiSummary.ts | 34 +++++++++++++++ 3 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 src/feature/album/4cut/hooks/use4CutAiSummary.ts diff --git a/src/feature/album/4cut/components/Container4CutExplanation.tsx b/src/feature/album/4cut/components/Container4CutExplanation.tsx index 39712b30..a7f1ddc6 100644 --- a/src/feature/album/4cut/components/Container4CutExplanation.tsx +++ b/src/feature/album/4cut/components/Container4CutExplanation.tsx @@ -1,6 +1,8 @@ import { X } from 'lucide-react'; +import { use4CutAiSummary } from '../hooks/use4CutAiSummary'; interface Container4CutExplanationProps { + albumId: string; eventName?: string; eventDate?: string; scale?: number; @@ -14,6 +16,7 @@ const BASE_HEIGHT = 384; const BASE_ASPECT_RATIO = BASE_HEIGHT / BASE_WIDTH; export default function Container4CutExplanation({ + albumId, eventName, eventDate, scale = 1, @@ -23,6 +26,7 @@ export default function Container4CutExplanation({ }: Container4CutExplanationProps) { const calculatedWidth = width ?? BASE_WIDTH * scale; const calculatedHeight = calculatedWidth * BASE_ASPECT_RATIO; + const { aiSummary, isCompleted, isLoading } = use4CutAiSummary(albumId); return (
{eventDate && ( -

{eventDate}

+

+ {eventDate} +

)}
{/* X 버튼 */} @@ -59,32 +65,17 @@ export default function Container4CutExplanation({ {/* Body: 텍스트 설명 */}
-
-

- 여러분의 소중한 순간을 4장의 사진으로 담아내는 특별한 기능입니다. -

- -
-

- ✨ 특징 -

-
    -
  • • 앨범의 베스트 사진 4장 자동 선정
  • -
  • • 인생네컷 스타일 레이아웃
  • -
  • • 다운로드 및 공유 가능
  • -
+ {isLoading ? ( +
+
+ AI 요약 생성 중... +
- -
-

- 💡 사용 방법 -

-

- 메이커가 사진을 확정하면 자동으로 생성됩니다. 다운로드 버튼을 - 눌러 저장하세요! -

-
-
+ ) : ( +

+ {aiSummary} +

+ )}
diff --git a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx index da5d059e..f9c680b1 100644 --- a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx +++ b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx @@ -248,6 +248,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { }} > { + 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 || '', + }; +} From c6b38f5db3e929760b69fa4fa9ae4fd5f5eef96b Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 15:39:50 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=204=EC=BB=B7=20AI=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EB=92=A4=EC=A7=91=EA=B8=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/feature/album/4cut/components/ScreenAlbum4Cut.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx index f9c680b1..1787d3cb 100644 --- a/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx +++ b/src/feature/album/4cut/components/ScreenAlbum4Cut.tsx @@ -21,6 +21,7 @@ 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'; @@ -42,6 +43,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { const { data } = useGetAlbumInfo(albumId); const { data: albumInformData } = useGetAlbumInform({ code: albumId }); const { data: { name } = {} } = useGetUserMe(); + const { isCompleted } = use4CutAiSummary(albumId); // TODO : openapi type이 이상해서 임시 any처리. 백엔드랑 협의 필요 const { @@ -101,6 +103,9 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) { }; const handleFlipCard = () => { + if (!isCompleted && !showExplanation) { + return; + } setShowExplanation(!showExplanation); }; From 665c8544e55795000e26dbf6405cd2daf6fe6ca8 Mon Sep 17 00:00:00 2001 From: dasosann Date: Fri, 6 Feb 2026 15:50:56 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EC=82=AD=EC=A0=9C=20=EA=B0=80=EB=8A=A5=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/feature/album/detail/components/ScreenAlbumDetail.tsx | 1 + 1 file changed, 1 insertion(+) 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, })); } From 08647e1cecacbcd5656a6399ab60ff05437908af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B7=9C=ED=83=9C?= Date: Fri, 6 Feb 2026 16:30:48 +0900 Subject: [PATCH 7/7] Update src/feature/album/4cut/components/Container4CutExplanation.tsx delete iscompleted in AiSummary Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/feature/album/4cut/components/Container4CutExplanation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/feature/album/4cut/components/Container4CutExplanation.tsx b/src/feature/album/4cut/components/Container4CutExplanation.tsx index a7f1ddc6..8878f067 100644 --- a/src/feature/album/4cut/components/Container4CutExplanation.tsx +++ b/src/feature/album/4cut/components/Container4CutExplanation.tsx @@ -26,7 +26,7 @@ export default function Container4CutExplanation({ }: Container4CutExplanationProps) { const calculatedWidth = width ?? BASE_WIDTH * scale; const calculatedHeight = calculatedWidth * BASE_ASPECT_RATIO; - const { aiSummary, isCompleted, isLoading } = use4CutAiSummary(albumId); + const { aiSummary, isLoading } = use4CutAiSummary(albumId); return (