AI 이미지 생성 기능 완성 및 실제 API 연동

주요 변경사항:
- Step flow 통합: localStorage 기반 eventId 사용
- 자동 이미지 생성: 이미지 없을 시 자동 생성 트리거
- 진행률 바 추가: 0-100% 진행률 표시
- 동적 로딩 메시지: 단계별 메시지 업데이트
- Next.js 15 API routes 수정: params를 Promise로 처리
- 실제 배포 API 연동: Content API 서버 URL 설정

기술 세부사항:
- API proxy routes 추가 (CORS 우회)
- 2초 폴링 메커니즘 (최대 60초)
- 환경변수: NEXT_PUBLIC_CONTENT_API_URL 설정
- CDN URL 디버그 오버레이 제거

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250
2025-10-28 23:08:57 +09:00
parent 135e5c5635
commit 6cccafa822
17 changed files with 2316 additions and 115 deletions
+19 -4
View File
@@ -15,12 +15,16 @@ export type BudgetLevel = 'low' | 'medium' | 'high';
export type EventMethod = 'online' | 'offline';
export interface EventData {
eventDraftId?: number;
objective?: EventObjective;
recommendation?: {
budget: BudgetLevel;
method: EventMethod;
title: string;
prize: string;
description?: string;
industry?: string;
location?: string;
participationMethod: string;
expectedParticipants: number;
estimatedCost: number;
@@ -28,6 +32,7 @@ export interface EventData {
};
contentPreview?: {
imageStyle: string;
images?: any[];
};
contentEdit?: {
title: string;
@@ -89,6 +94,18 @@ export default function EventCreatePage() {
);
if (needsContent) {
// localStorage에 이벤트 정보 저장
const eventData = {
eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성
eventTitle: context.recommendation?.title || '',
eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '',
industry: context.recommendation?.industry || '',
location: context.recommendation?.location || '',
trends: [], // 필요시 context에서 추가
prize: context.recommendation?.prize || '',
};
localStorage.setItem('eventCreationData', JSON.stringify(eventData));
history.push('contentPreview', { ...context, channels });
} else {
history.push('approval', { ...context, channels });
@@ -101,12 +118,10 @@ export default function EventCreatePage() {
)}
contentPreview={({ context, history }) => (
<ContentPreviewStep
title={context.recommendation?.title || ''}
prize={context.recommendation?.prize || ''}
onNext={(imageStyle) => {
onNext={(imageStyle, images) => {
history.push('contentEdit', {
...context,
contentPreview: { imageStyle },
contentPreview: { imageStyle, images },
});
}}
onSkip={() => {
@@ -12,8 +12,11 @@ import {
IconButton,
Dialog,
Grid,
Alert,
} from '@mui/material';
import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material';
import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material';
import { contentApi, ImageInfo } from '@/shared/api/contentApi';
import Image from 'next/image';
// 디자인 시스템 색상
const colors = {
@@ -34,7 +37,7 @@ const colors = {
};
interface ImageStyle {
id: string;
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
name: string;
gradient?: string;
icon: string;
@@ -43,19 +46,19 @@ interface ImageStyle {
const imageStyles: ImageStyle[] = [
{
id: 'simple',
id: 'SIMPLE',
name: '스타일 1: 심플',
icon: 'celebration',
},
{
id: 'fancy',
id: 'FANCY',
name: '스타일 2: 화려',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'auto_awesome',
textColor: 'white',
},
{
id: 'trendy',
id: 'TRENDY',
name: '스타일 3: 트렌디',
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'trending_up',
@@ -64,50 +67,230 @@ const imageStyles: ImageStyle[] = [
];
interface ContentPreviewStepProps {
title: string;
prize: string;
onNext: (imageStyle: string) => void;
onNext: (imageStyle: string, images: ImageInfo[]) => void;
onSkip: () => void;
onBack: () => void;
}
interface EventCreationData {
eventDraftId: string; // Changed from number to string
eventTitle: string;
eventDescription: string;
industry: string;
location: string;
trends: string[];
prize: string;
}
export default function ContentPreviewStep({
title,
prize,
onNext,
onSkip,
onBack,
}: ContentPreviewStepProps) {
const [loading, setLoading] = useState(true);
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [fullscreenStyle, setFullscreenStyle] = useState<ImageStyle | null>(null);
const [fullscreenImage, setFullscreenImage] = useState<ImageInfo | null>(null);
const [generatedImages, setGeneratedImages] = useState<Map<string, ImageInfo>>(new Map());
const [error, setError] = useState<string | null>(null);
const [loadingProgress, setLoadingProgress] = useState(0);
const [loadingMessage, setLoadingMessage] = useState('이미지 생성 요청 중...');
const [eventData, setEventData] = useState<EventCreationData | null>(null);
useEffect(() => {
// AI 이미지 생성 시뮬레이션
const timer = setTimeout(() => {
setLoading(false);
}, 5000);
// localStorage에서 이벤트 데이터 읽기
const storedData = localStorage.getItem('eventCreationData');
if (storedData) {
const data: EventCreationData = JSON.parse(storedData);
setEventData(data);
return () => clearTimeout(timer);
// 먼저 이미지 조회 시도
loadImages(data).then((hasImages) => {
// 이미지가 없으면 자동으로 생성
if (!hasImages) {
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} else {
// Mock 데이터가 없으면 자동으로 설정
const mockData: EventCreationData = {
eventDraftId: "1761634317010", // Changed to string
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
};
console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.');
localStorage.setItem('eventCreationData', JSON.stringify(mockData));
setEventData(mockData);
loadImages(mockData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleStyleSelect = (styleId: string) => {
const loadImages = async (data: EventCreationData): Promise<boolean> => {
try {
setError(null);
console.log('📥 Loading images for event:', data.eventDraftId);
const images = await contentApi.getImages(data.eventDraftId);
console.log('✅ Images loaded from API:', images.length, images);
if (!images || images.length === 0) {
console.warn('⚠️ No images found.');
return false; // 이미지 없음
}
const imageMap = new Map<string, ImageInfo>();
// 각 스타일별로 가장 최신 이미지만 선택 (createdAt 기준)
images.forEach((image, index) => {
console.log(`📸 Processing image ${index + 1}:`, {
id: image.id,
eventId: image.eventId,
style: image.style,
platform: image.platform,
cdnUrl: image.cdnUrl?.substring(0, 50) + '...',
createdAt: image.createdAt,
});
if (image.platform === 'INSTAGRAM') {
const existing = imageMap.get(image.style);
if (!existing || new Date(image.createdAt) > new Date(existing.createdAt)) {
console.log(` ✅ Selected as latest ${image.style} image`);
imageMap.set(image.style, image);
} else {
console.log(` ⏭️ Skipped (older than existing ${image.style} image)`);
}
} else {
console.log(` ⏭️ Skipped (platform: ${image.platform})`);
}
});
console.log('🎨 Image map created with entries:', {
SIMPLE: imageMap.has('SIMPLE') ? 'YES ✅' : 'NO ❌',
FANCY: imageMap.has('FANCY') ? 'YES ✅' : 'NO ❌',
TRENDY: imageMap.has('TRENDY') ? 'YES ✅' : 'NO ❌',
totalSize: imageMap.size,
});
console.log('🖼️ Image map details:', Array.from(imageMap.entries()).map(([style, img]) => ({
style,
id: img.id,
eventId: img.eventId,
cdnUrl: img.cdnUrl?.substring(0, 60) + '...',
})));
setGeneratedImages(imageMap);
console.log('✅ Images loaded successfully!');
return true; // 이미지 있음
} catch (err) {
console.error('❌ Load images error:', err);
// API 에러는 polling에서 무시 (계속 시도)
return false;
}
};
const handleStyleSelect = (styleId: 'SIMPLE' | 'FANCY' | 'TRENDY') => {
setSelectedStyle(styleId);
};
const handlePreview = (style: ImageStyle, e: React.MouseEvent) => {
const handlePreview = (image: ImageInfo, e: React.MouseEvent) => {
e.stopPropagation();
setFullscreenStyle(style);
setFullscreenImage(image);
setFullscreenOpen(true);
};
const handleNext = () => {
if (selectedStyle) {
onNext(selectedStyle);
const allImages = Array.from(generatedImages.values());
onNext(selectedStyle, allImages);
}
};
const handleGenerateImagesAuto = async (data: EventCreationData) => {
try {
setLoading(true);
setError(null);
setLoadingProgress(0);
setLoadingMessage('이미지 생성 요청 중...');
console.log('🎨 Auto-generating images for event:', data.eventDraftId);
// 이미지 생성 요청 (202 Accepted 응답만 확인)
await contentApi.generateImages({
eventId: data.eventDraftId,
eventTitle: data.eventTitle,
eventDescription: data.eventDescription,
industry: data.industry,
location: data.location,
trends: data.trends,
styles: ['SIMPLE', 'FANCY', 'TRENDY'],
platforms: ['INSTAGRAM'],
});
console.log('✅ Image generation request accepted (202)');
console.log('⏳ AI 이미지 생성 중... 약 60초 소요됩니다.');
setLoadingProgress(10);
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
// 생성 완료까지 대기 (polling)
let attempts = 0;
const maxAttempts = 30; // 최대 60초 (2초 * 30회)
const pollImages = async () => {
attempts++;
console.log(`🔄 이미지 확인 시도 ${attempts}/${maxAttempts}...`);
// 진행률 업데이트 (10% ~ 90%)
const progress = Math.min(10 + (attempts / maxAttempts) * 80, 90);
setLoadingProgress(progress);
// 단계별 메시지 업데이트
if (attempts < 10) {
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
} else if (attempts < 20) {
setLoadingMessage('스타일을 적용하고 있어요...');
} else {
setLoadingMessage('거의 완료되었어요...');
}
const hasImages = await loadImages(data);
if (hasImages) {
console.log('✅ 이미지 생성 완료!');
setLoadingProgress(100);
setLoadingMessage('이미지 생성 완료!');
setTimeout(() => setLoading(false), 500); // 100% 잠깐 보여주기
} else if (attempts < maxAttempts) {
// 2초 후 다시 시도
setTimeout(pollImages, 2000);
} else {
console.warn('⚠️ 이미지 생성 시간 초과. "이미지 재생성" 버튼을 클릭하세요.');
setError('이미지 생성이 완료되지 않았습니다. 잠시 후 "이미지 재생성" 버튼을 클릭해주세요.');
setLoading(false);
}
};
// 첫 번째 확인은 5초 후 시작 (생성 시작 시간 고려)
setTimeout(pollImages, 5000);
} catch (err) {
console.error('❌ Image generation request error:', err);
setError('이미지 생성 요청에 실패했습니다.');
setLoading(false);
}
};
const handleGenerateImages = async () => {
if (!eventData) return;
handleGenerateImagesAuto(eventData);
};
if (loading) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
@@ -121,7 +304,7 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15 }}>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
{/* 그라데이션 스피너 */}
<Box
sx={{
@@ -161,17 +344,69 @@ export default function ContentPreviewStep({
}}
/>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, fontSize: '1.5rem' }}>
AI
</Typography>
{/* 진행률 바 */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
{loadingMessage}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: colors.purple, fontSize: '1.25rem' }}>
{Math.round(loadingProgress)}%
</Typography>
</Box>
<Box
sx={{
width: '100%',
height: 8,
bgcolor: 'rgba(0,0,0,0.1)',
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
}}
>
<Box
sx={{
width: `${loadingProgress}%`,
height: '100%',
background: `linear-gradient(90deg, ${colors.purple}, ${colors.pink})`,
borderRadius: 4,
transition: 'width 0.3s ease-in-out',
}}
/>
</Box>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, fontSize: '1.125rem' }}>
<br />
...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
시간: 5초
{generatedImages.size > 0 ? (
<>
<br />
!
</>
) : (
<>
AI가
<br />
60
</>
)}
</Typography>
{error && (
<Alert severity="error" sx={{ maxWidth: 400, mx: 'auto', mt: 3 }}>
{error}
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{ mt: 2 }}
>
</Button>
</Alert>
)}
</Box>
</Container>
</Box>
@@ -179,7 +414,18 @@ export default function ContentPreviewStep({
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Box
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
pb: 20,
animation: 'fadeIn 0.5s ease-in',
'@keyframes fadeIn': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
}}
>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
@@ -191,11 +437,35 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
{generatedImages.size > 0 && (
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
</Alert>
)}
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{
ml: 4,
py: 2,
px: 4,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
</Typography>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value)}>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{imageStyles.map((style) => (
<Grid item xs={12} md={4} key={style.id}>
@@ -237,46 +507,82 @@ export default function ContentPreviewStep({
sx={{
width: '100%',
aspectRatio: '1 / 1',
background: style.gradient || colors.gray[100],
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
position: 'relative',
overflow: 'hidden',
bgcolor: colors.gray[100],
}}
>
<span
className="material-icons"
style={{
fontSize: 64,
marginBottom: 24,
color: style.textColor || colors.gray[700],
}}
>
{style.icon}
</span>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 2,
color: style.textColor || 'text.primary',
fontSize: '1.25rem',
}}
>
{title}
</Typography>
<Typography
variant="body1"
sx={{
color: style.textColor || 'text.secondary',
opacity: style.textColor ? 0.9 : 1,
fontSize: '1rem',
}}
>
{prize}
</Typography>
{(() => {
const hasImage = generatedImages.has(style.id);
const imageData = generatedImages.get(style.id);
console.log(`🖼️ Rendering ${style.id}:`, {
hasImage,
imageDataExists: !!imageData,
fullCdnUrl: imageData?.cdnUrl,
mapSize: generatedImages.size,
mapKeys: Array.from(generatedImages.keys()),
});
return hasImage && imageData ? (
<Image
src={imageData.cdnUrl}
alt={style.name}
fill
style={{ objectFit: 'cover' }}
unoptimized
onLoad={() => console.log(`${style.id} image loaded successfully from:`, imageData.cdnUrl)}
onError={(e) => {
console.error(`${style.id} image load error:`, e);
console.error(` Failed URL:`, imageData.cdnUrl);
}}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
background: style.gradient || colors.gray[100],
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
}}
>
<span
className="material-icons"
style={{
fontSize: 64,
marginBottom: 24,
color: style.textColor || colors.gray[700],
}}
>
{style.icon}
</span>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 2,
color: style.textColor || 'text.primary',
fontSize: '1.25rem',
}}
>
{eventData?.eventTitle || '이벤트'}
</Typography>
<Typography
variant="body1"
sx={{
color: style.textColor || 'text.secondary',
opacity: style.textColor ? 0.9 : 1,
fontSize: '1rem',
}}
>
{eventData?.prize || '경품'}
</Typography>
</Box>
);
})()}
</Box>
{/* 크게보기 버튼 */}
@@ -284,7 +590,13 @@ export default function ContentPreviewStep({
<Button
variant="outlined"
startIcon={<ZoomIn />}
onClick={(e) => handlePreview(style, e)}
onClick={(e) => {
const image = generatedImages.get(style.id);
if (image) {
handlePreview(image, e);
}
}}
disabled={!generatedImages.has(style.id)}
sx={{
borderRadius: 2,
py: 1.5,
@@ -387,51 +699,24 @@ export default function ContentPreviewStep({
<span className="material-icons">close</span>
</IconButton>
{fullscreenStyle && (
{fullscreenImage && (
<Box
sx={{
width: '100%',
maxWidth: 600,
maxWidth: 800,
aspectRatio: '1 / 1',
background: fullscreenStyle.gradient || '#f5f5f5',
position: 'relative',
borderRadius: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
overflow: 'hidden',
}}
>
<span
className="material-icons"
style={{
fontSize: 80,
marginBottom: 24,
color: fullscreenStyle.textColor || 'inherit',
}}
>
{fullscreenStyle.icon}
</span>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 2,
color: fullscreenStyle.textColor || 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="h6"
sx={{
color: fullscreenStyle.textColor || 'text.secondary',
opacity: fullscreenStyle.textColor ? 0.9 : 1,
}}
>
{prize}
</Typography>
<Image
src={fullscreenImage.cdnUrl}
alt={`${fullscreenImage.style} style`}
fill
style={{ objectFit: 'contain' }}
unoptimized
/>
</Box>
)}
</Box>
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ eventDraftId: string }> }
) {
try {
const { eventDraftId } = await context.params;
const { searchParams } = new URL(request.url);
const style = searchParams.get('style');
const platform = searchParams.get('platform');
// eventDraftId is now eventId in the API
let url = `${CONTENT_API_BASE_URL}/api/v1/content/events/${eventDraftId}/images`;
const queryParams = [];
if (style) queryParams.push(`style=${style}`);
if (platform) queryParams.push(`platform=${platform}`);
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`;
}
console.log('🔄 Proxying images request to Content API:', { url });
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log('🔄 Proxying image generation request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/generate`,
body,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to generate images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Image generation job created:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ jobId: string }> }
) {
try {
const { jobId } = await context.params;
console.log('🔄 Proxying job status request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get job status', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import axios, { AxiosInstance } from 'axios';
// Use Next.js API proxy to bypass CORS issues
const CONTENT_API_BASE_URL = '/api/content';
export const contentApiClient: AxiosInstance = axios.create({
baseURL: CONTENT_API_BASE_URL,
timeout: 120000, // 이미지 생성은 시간이 오래 걸릴 수 있으므로 120초
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
contentApiClient.interceptors.request.use(
(config) => {
console.log('🎨 Content API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
data: config.data,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ Content API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
contentApiClient.interceptors.response.use(
(response) => {
console.log('✅ Content API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ Content API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
data: error.response?.data,
});
return Promise.reject(error);
}
);
// Types
export interface GenerateImagesRequest {
eventId: string;
eventTitle: string;
eventDescription: string;
industry?: string;
location?: string;
trends?: string[];
styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[];
platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[];
}
export interface JobInfo {
id: string;
eventId: string;
jobType: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultMessage?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
export interface ImageInfo {
id: number;
eventId: string;
style: 'SIMPLE' | 'FANCY' | 'TRENDY';
platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO';
cdnUrl: string;
prompt: string;
selected: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContentInfo {
id: number;
eventId: string;
eventTitle: string;
eventDescription: string;
images: ImageInfo[];
createdAt: string;
updatedAt: string;
}
// API Functions
export const contentApi = {
// 이미지 생성 (Next.js API proxy 사용)
generateImages: async (request: GenerateImagesRequest): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>('/images/generate', request);
return response.data;
},
// Job 상태 조회 (Next.js API proxy 사용)
getJobStatus: async (jobId: string): Promise<JobInfo> => {
const response = await contentApiClient.get<JobInfo>(`/images/jobs/${jobId}`);
return response.data;
},
// 이벤트별 콘텐츠 조회
getContentByEventId: async (eventId: string): Promise<ContentInfo> => {
const response = await contentApiClient.get<ContentInfo>(`/events/${eventId}`);
return response.data;
},
// 이미지 목록 조회 (Next.js API proxy 사용)
getImages: async (
eventId: string,
style?: 'SIMPLE' | 'FANCY' | 'TRENDY',
platform?: 'INSTAGRAM' | 'NAVER' | 'KAKAO'
): Promise<ImageInfo[]> => {
const params = new URLSearchParams();
if (style) params.append('style', style);
if (platform) params.append('platform', platform);
const response = await contentApiClient.get<ImageInfo[]>(
`/events/${eventId}/images${params.toString() ? `?${params.toString()}` : ''}`
);
return response.data;
},
// 특정 이미지 조회
getImageById: async (imageId: number): Promise<ImageInfo> => {
const response = await contentApiClient.get<ImageInfo>(`/api/v1/content/images/${imageId}`);
return response.data;
},
// 이미지 삭제
deleteImage: async (imageId: number): Promise<void> => {
await contentApiClient.delete(`/api/v1/content/images/${imageId}`);
},
// 이미지 재생성
regenerateImage: async (imageId: number, newPrompt?: string): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>(
`/api/v1/content/images/${imageId}/regenerate`,
{ imageId, newPrompt }
);
return response.data;
},
};
export default contentApi;