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
3 changes: 3 additions & 0 deletions src/feature/album/4cut/components/Capture4CutPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface Capture4CutPortalProps {
albumId: string;
eventName?: string;
eventDate?: string;
isFinalized?: boolean;
}

const Capture4CutPortal = ({
Expand All @@ -18,6 +19,7 @@ const Capture4CutPortal = ({
albumId,
eventName,
eventDate,
isFinalized = false,
}: Capture4CutPortalProps) => (
<BodyPortal>
<div
Expand All @@ -34,6 +36,7 @@ const Capture4CutPortal = ({
eventName={eventName}
eventDate={eventDate}
width={500}
isFinalized={isFinalized}
/>
</div>
</BodyPortal>
Expand Down
9 changes: 8 additions & 1 deletion src/feature/album/4cut/components/Container4Cut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface Container4CutProps {
eventDate?: string;
scale?: number;
width?: number;
isFinalized?: boolean;
}

const BASE_WIDTH = 216;
Expand All @@ -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
Expand Down Expand Up @@ -64,7 +66,12 @@ export default function Container4Cut({
return (
<div
className='border-border-primary text-text-secondary relative border font-medium'
style={{ fontSize: scaledFontSize }}
style={{
fontSize: scaledFontSize,
...(isFinalized && {
boxShadow: '0px 0px 25px 5px rgba(0, 0, 0, 0.08)',
}),
}}
Comment on lines +69 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

인라인 스타일 대신 Tailwind CSS 클래스를 사용하여 조건부 스타일을 적용하는 것이 유지보수 측면에서 더 좋습니다. boxShadow를 별도의 클래스로 정의하고 isFinalized 값에 따라 동적으로 클래스를 적용하는 것을 고려해보세요. 예를 들어, clsx와 같은 유틸리티를 사용할 수 있습니다.

>
<Svg4Cut
width={calculatedWidth}
Expand Down
83 changes: 83 additions & 0 deletions src/feature/album/4cut/components/Container4CutExplanation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { X } from 'lucide-react';
import { use4CutAiSummary } from '../hooks/use4CutAiSummary';

interface Container4CutExplanationProps {
albumId: string;
eventName?: string;
eventDate?: string;
scale?: number;
width?: number;
isFinalized?: boolean;
onClose?: () => 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 (
<div
className='bg-element-letter relative overflow-hidden font-medium'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

자식 요소의 높이 문제로 인한 레이아웃 깨짐을 방지하기 위해, 이 컨테이너를 flexbox 컨테이너로 만드는 것을 제안합니다. flex flex-col을 추가해주세요.

Suggested change
className='bg-element-letter relative overflow-hidden font-medium'
className='bg-element-letter relative flex flex-col overflow-hidden font-medium'

style={{
width: `${calculatedWidth}px`,
height: `${calculatedHeight}px`,
borderRadius: '8px',
...(isFinalized && {
boxShadow: '0px 0px 25px 5px rgba(0, 0, 0, 0.08)',
}),
}}
Comment on lines +38 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

인라인 스타일 대신 Tailwind CSS 클래스를 사용하여 조건부 스타일을 적용하는 것이 유지보수 측면에서 더 좋습니다. boxShadow를 별도의 클래스로 정의하고 isFinalized 값에 따라 동적으로 클래스를 적용하는 것을 고려해보세요.

>
{/* 헤더: 제목 + 날짜 + X 버튼 */}
<div className='flex justify-between p-4'>
<div className='flex flex-col gap-1'>
<h2 className='text-text-basic typo-body-lg-semibold'>
{eventName || '인생네컷'}
</h2>
{eventDate && (
<p className='text-text-subtler typo-caption-sm-medium'>
{eventDate}
</p>
)}
</div>
{/* X 버튼 */}
<button
onClick={onClose}
className='rounded-full p-1 transition-colors hover:bg-gray-100'
aria-label='닫기'
>
<X className='text-text-secondary h-5 w-5' />
</button>
</div>

{/* Body: 텍스트 설명 */}
<div className='flex h-full flex-col justify-center p-4'>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

h-full 클래스는 부모 높이만큼을 차지하게 만들어 헤더와 내용이 겹칠 수 있습니다. flex-grow를 사용하여 남은 공간을 모두 차지하도록 변경하면 이 문제를 해결할 수 있습니다.

Suggested change
<div className='flex h-full flex-col justify-center p-4'>
<div className='flex flex-grow flex-col justify-center p-4'>

<div className='bg-surface-white rounded-lg p-4'>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<div className='text-text-secondary text-sm'>
AI 요약 생성 중...
</div>
</div>
) : (
<p className='text-text-basic text-sm leading-relaxed whitespace-pre-wrap'>
{aiSummary}
</p>
)}
</div>
</div>
</div>
);
}
81 changes: 71 additions & 10 deletions src/feature/album/4cut/components/ScreenAlbum4Cut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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<HTMLDivElement>(null);
const { data } = useGetAlbumInfo(albumId);
const { data: albumInformData } = useGetAlbumInform({ code: albumId });
const { data: { name } = {} } = useGetUserMe();
const { isCompleted } = use4CutAiSummary(albumId);

// TODO : openapi type이 이상해서 임시 any처리. 백엔드랑 협의 필요
const {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -196,15 +207,64 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) {
{!isFinalized && (
<div className='typo-body-lg-semibold mb-2'>현재 TOP 4 사진</div>
)}
<div>
<Container4Cut
albumId={albumId}
eventName={data?.title}
eventDate={
data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''
}
scale={1}
/>
<div
className='relative cursor-pointer'
style={{
perspective: '1000px',
}}
onClick={isFinalized ? handleFlipCard : undefined}
>
<div
style={{
transformStyle: 'preserve-3d',
transition: 'transform 0.6s',
transform: showExplanation
? 'rotateY(180deg)'
: 'rotateY(0deg)',
}}
>
{/* 앞면 - 4컷 사진 */}
<div
style={{
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
}}
>
<Container4Cut
albumId={albumId}
eventName={data?.title}
eventDate={
data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''
}
scale={isFinalized ? 1.5 : 1}
isFinalized={isFinalized}
/>
</div>
{/* 뒷면 - 설명 */}
{isFinalized && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
backfaceVisibility: 'hidden',
WebkitBackfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<Container4CutExplanation
albumId={albumId}
eventName={data?.title}
eventDate={
data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''
}
scale={1.5}
isFinalized={isFinalized}
onClose={handleFlipCard}
/>
</div>
)}
</div>
Comment on lines +217 to +267
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

플립 애니메이션을 위한 스타일이 인라인으로 작성되어 있어 코드가 길고 복잡해 보입니다. Tailwind CSS의 JIT 모드를 활용하여 tailwind.config.js에 커스텀 스타일을 추가하거나, 별도의 CSS 클래스로 분리하여 코드 가독성과 유지보수성을 높이는 것을 권장합니다.

</div>
</section>
{!is4CutPreviewPending && (
Expand Down Expand Up @@ -286,7 +346,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) {
)}{' '}
</main>{' '}
{isDownloading && (
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-[2px]'>
<div className='fixed inset-0 z-9999 flex items-center justify-center bg-black/40 backdrop-blur-[2px]'>
<div className='flex items-center gap-3 rounded-2xl bg-white px-4 py-3 shadow-lg'>
<Loader2 className='text-primary h-5 w-5 animate-spin' />
<span className='typo-body-lg-semibold text-text-basic'>
Expand All @@ -301,6 +361,7 @@ export default function ScreenAlbum4Cut({ albumId }: ScreenAlbum4CutProps) {
albumId={albumId}
eventName={data?.title}
eventDate={data?.eventDate ? data.eventDate.replace(/-/g, '.') : ''}
isFinalized={isFinalized}
/>
</>
);
Expand Down
34 changes: 34 additions & 0 deletions src/feature/album/4cut/hooks/use4CutAiSummary.ts
Original file line number Diff line number Diff line change
@@ -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<ApiReturns['cheese4cut.cheese4cutAiSummary']>({
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

30000이라는 매직 넘버 대신, 파일 상단에 const POLLING_INTERVAL_MS = 30000;과 같이 의미 있는 이름의 상수로 정의하여 사용하면 가독성과 유지보수성을 높일 수 있습니다.

Suggested change
return 30000;
return 30000; // POLLING_INTERVAL_MS

},
refetchIntervalInBackground: false,
});

return {
...query,
aiSummary: query.data?.content || '',
isCompleted: query.data?.status === 'COMPLETED',
title: query.data?.title || '',
};
}
4 changes: 2 additions & 2 deletions src/feature/album/4cut/hooks/use4CutFixed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ interface Cheese4CutFixedProps {
}

const fetchData = async ({ albumId, photoIds }: Cheese4CutFixedProps) => {
const res = await api.post<ApiReturns['cheese4cut.finalize']>({
path: EP.cheese4cut.finalize(albumId),
const res = await api.post<ApiReturns['cheese4cut.cheese4cutFixedAi']>({
path: EP.cheese4cut.cheese4cutFixedAi(albumId),
body: { photoIds },
});
return res.result;
Expand Down
1 change: 1 addition & 0 deletions src/feature/album/detail/components/ScreenAlbumDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,5 +250,6 @@ function mapLikedPhotosToPhotoList(
isLiked: item.isLiked ?? false,
isDownloaded: item.isDownloaded,
isRecentlyDownloaded: item.isRecentlyDownloaded,
canDelete: false,
}));
}
10 changes: 9 additions & 1 deletion src/global/api/ep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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"];

Expand Down Expand Up @@ -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
}
Loading