mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2026-06-13 09:39:09 +00:00
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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user