mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:56:24 +00:00
- Next.js API 프록시 라우트 8개 생성 (User/Event Analytics) - analyticsClient baseURL을 프록시 경로로 변경 - analyticsApi 경로에서 /api/v1 접두사 제거 - 404/400 에러에 대한 사용자 친화적 에러 처리 추가 - Dashboard, Event Detail, Analytics 페이지 에러 핸들링 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
743 lines
25 KiB
TypeScript
743 lines
25 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
Container,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Button,
|
|
Radio,
|
|
RadioGroup,
|
|
FormControlLabel,
|
|
IconButton,
|
|
Dialog,
|
|
Grid,
|
|
Alert,
|
|
} from '@mui/material';
|
|
import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material';
|
|
import { contentApi, ImageInfo } from '@/shared/api/contentApi';
|
|
import Image from 'next/image';
|
|
|
|
// 디자인 시스템 색상
|
|
const colors = {
|
|
pink: '#F472B6',
|
|
purple: '#C084FC',
|
|
purpleLight: '#E9D5FF',
|
|
blue: '#60A5FA',
|
|
mint: '#34D399',
|
|
orange: '#FB923C',
|
|
yellow: '#FBBF24',
|
|
gray: {
|
|
900: '#1A1A1A',
|
|
700: '#4A4A4A',
|
|
500: '#9E9E9E',
|
|
300: '#D9D9D9',
|
|
100: '#F5F5F5',
|
|
},
|
|
};
|
|
|
|
interface ImageStyle {
|
|
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
|
|
name: string;
|
|
gradient?: string;
|
|
icon: string;
|
|
textColor?: string;
|
|
}
|
|
|
|
const imageStyles: ImageStyle[] = [
|
|
{
|
|
id: 'SIMPLE',
|
|
name: '스타일 1: 심플',
|
|
icon: 'celebration',
|
|
},
|
|
{
|
|
id: 'FANCY',
|
|
name: '스타일 2: 화려',
|
|
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
icon: 'auto_awesome',
|
|
textColor: 'white',
|
|
},
|
|
{
|
|
id: 'TRENDY',
|
|
name: '스타일 3: 트렌디',
|
|
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
|
icon: 'trending_up',
|
|
textColor: 'white',
|
|
},
|
|
];
|
|
|
|
interface ContentPreviewStepProps {
|
|
eventId?: string;
|
|
eventTitle?: string;
|
|
eventDescription?: string;
|
|
onNext: (imageStyle: string, images: ImageInfo[]) => void;
|
|
onSkip: () => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
interface EventCreationData {
|
|
eventDraftId: string;
|
|
eventTitle: string;
|
|
eventDescription: string;
|
|
industry: string;
|
|
location: string;
|
|
trends: string[];
|
|
prize: string;
|
|
}
|
|
|
|
export default function ContentPreviewStep({
|
|
eventId: propsEventId,
|
|
eventTitle: propsEventTitle,
|
|
eventDescription: propsEventDescription,
|
|
onNext,
|
|
onSkip,
|
|
onBack,
|
|
}: ContentPreviewStepProps) {
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null);
|
|
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
|
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(() => {
|
|
// localStorage에서 이벤트 데이터 읽기
|
|
const storedData = localStorage.getItem('eventCreationData');
|
|
if (storedData) {
|
|
const data: EventCreationData = JSON.parse(storedData);
|
|
setEventData(data);
|
|
|
|
// 먼저 이미지 조회 시도
|
|
loadImages(data).then((hasImages) => {
|
|
// 이미지가 없으면 자동으로 생성
|
|
if (!hasImages) {
|
|
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
|
|
handleGenerateImagesAuto(data);
|
|
}
|
|
});
|
|
} else if (propsEventId) {
|
|
// Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만)
|
|
console.log('✅ Using event data from props:', propsEventId);
|
|
const data: EventCreationData = {
|
|
eventDraftId: propsEventId,
|
|
eventTitle: propsEventTitle || '',
|
|
eventDescription: propsEventDescription || '',
|
|
industry: '',
|
|
location: '',
|
|
trends: [],
|
|
prize: '',
|
|
};
|
|
setEventData(data);
|
|
|
|
// 이미지 조회 시도
|
|
loadImages(data).then((hasImages) => {
|
|
if (!hasImages) {
|
|
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
|
|
handleGenerateImagesAuto(data);
|
|
}
|
|
});
|
|
} else {
|
|
// 이벤트 데이터가 없으면 에러 표시
|
|
console.error('❌ No event data available. Cannot proceed.');
|
|
setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.');
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [propsEventId, propsEventTitle, propsEventDescription]);
|
|
|
|
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 = (image: ImageInfo, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setFullscreenImage(image);
|
|
setFullscreenOpen(true);
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (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 }}>
|
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
|
<IconButton onClick={onBack}>
|
|
<ArrowBack />
|
|
</IconButton>
|
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
|
SNS 이미지 생성
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
|
|
{/* 그라데이션 스피너 */}
|
|
<Box
|
|
sx={{
|
|
width: 80,
|
|
height: 80,
|
|
margin: '0 auto 32px',
|
|
borderRadius: '50%',
|
|
background: `conic-gradient(from 0deg, ${colors.purple}, ${colors.pink}, ${colors.blue}, ${colors.purple})`,
|
|
animation: 'spin 1.5s linear infinite',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
'@keyframes spin': {
|
|
'0%': { transform: 'rotate(0deg)' },
|
|
'100%': { transform: 'rotate(360deg)' },
|
|
},
|
|
'&::before': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: '50%',
|
|
backgroundColor: 'background.default',
|
|
},
|
|
}}
|
|
>
|
|
<Psychology
|
|
sx={{
|
|
fontSize: 40,
|
|
color: colors.purple,
|
|
zIndex: 1,
|
|
animation: 'pulse 1.5s ease-in-out infinite',
|
|
'@keyframes pulse': {
|
|
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
|
'50%': { opacity: 0.7, transform: 'scale(0.95)' },
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 진행률 바 */}
|
|
<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' }}>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
|
{/* Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
|
<IconButton onClick={onBack}>
|
|
<ArrowBack />
|
|
</IconButton>
|
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
|
SNS 이미지 생성
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: { xs: 3, sm: 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: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
|
|
이벤트에 어울리는 스타일을 선택하세요
|
|
</Typography>
|
|
|
|
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
|
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
|
{imageStyles.map((style) => (
|
|
<Grid item xs={12} md={4} key={style.id}>
|
|
<Card
|
|
elevation={0}
|
|
sx={{
|
|
cursor: 'pointer',
|
|
borderRadius: 4,
|
|
border: selectedStyle === style.id ? 2 : 1,
|
|
borderColor: selectedStyle === style.id ? colors.purple : 'divider',
|
|
bgcolor: selectedStyle === style.id ? `${colors.purpleLight}40` : 'background.paper',
|
|
transition: 'all 0.3s',
|
|
position: 'relative',
|
|
boxShadow: selectedStyle === style.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
'&:hover': {
|
|
borderColor: colors.purple,
|
|
transform: 'translateY(-2px)',
|
|
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.15)',
|
|
},
|
|
}}
|
|
onClick={() => handleStyleSelect(style.id)}
|
|
>
|
|
<CardContent sx={{ p: 0 }}>
|
|
{/* 스타일 이름 */}
|
|
<Box sx={{ p: 4, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.125rem' }}>
|
|
{style.name}
|
|
</Typography>
|
|
<FormControlLabel
|
|
value={style.id}
|
|
control={<Radio sx={{ color: colors.purple, '&.Mui-checked': { color: colors.purple } }} />}
|
|
label=""
|
|
sx={{ m: 0 }}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 이미지 프리뷰 */}
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
aspectRatio: '1 / 1',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
bgcolor: colors.gray[100],
|
|
}}
|
|
>
|
|
{(() => {
|
|
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>
|
|
|
|
{/* 크게보기 버튼 */}
|
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<ZoomIn />}
|
|
onClick={(e) => {
|
|
const image = generatedImages.get(style.id);
|
|
if (image) {
|
|
handlePreview(image, e);
|
|
}
|
|
}}
|
|
disabled={!generatedImages.has(style.id)}
|
|
sx={{
|
|
borderRadius: 2,
|
|
py: 1.5,
|
|
px: 4,
|
|
fontSize: '0.875rem',
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
크게보기
|
|
</Button>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
</RadioGroup>
|
|
|
|
{/* Action Buttons */}
|
|
<Box sx={{ display: 'flex', gap: 4 }}>
|
|
<Button
|
|
fullWidth
|
|
variant="outlined"
|
|
size="large"
|
|
onClick={onSkip}
|
|
sx={{
|
|
py: 3,
|
|
borderRadius: 3,
|
|
fontSize: '1rem',
|
|
fontWeight: 600,
|
|
borderWidth: 2,
|
|
'&:hover': {
|
|
borderWidth: 2,
|
|
},
|
|
}}
|
|
>
|
|
건너뛰기
|
|
</Button>
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
size="large"
|
|
disabled={!selectedStyle}
|
|
onClick={handleNext}
|
|
sx={{
|
|
py: 3,
|
|
borderRadius: 3,
|
|
fontSize: '1rem',
|
|
fontWeight: 700,
|
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
|
'&:hover': {
|
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
|
opacity: 0.9,
|
|
},
|
|
'&:disabled': {
|
|
background: colors.gray[300],
|
|
},
|
|
}}
|
|
>
|
|
다음
|
|
</Button>
|
|
</Box>
|
|
</Container>
|
|
|
|
{/* Fullscreen Dialog */}
|
|
<Dialog
|
|
open={fullscreenOpen}
|
|
onClose={() => setFullscreenOpen(false)}
|
|
maxWidth={false}
|
|
PaperProps={{
|
|
sx: {
|
|
bgcolor: 'rgba(0, 0, 0, 0.95)',
|
|
boxShadow: 'none',
|
|
maxWidth: '90vw',
|
|
maxHeight: '90vh',
|
|
},
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
p: 4,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<IconButton
|
|
onClick={() => setFullscreenOpen(false)}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 16,
|
|
right: 16,
|
|
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
|
'&:hover': { bgcolor: 'white' },
|
|
}}
|
|
>
|
|
<span className="material-icons">close</span>
|
|
</IconButton>
|
|
|
|
{fullscreenImage && (
|
|
<Box
|
|
sx={{
|
|
width: '100%',
|
|
maxWidth: 800,
|
|
aspectRatio: '1 / 1',
|
|
position: 'relative',
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Image
|
|
src={fullscreenImage.cdnUrl}
|
|
alt={`${fullscreenImage.style} style`}
|
|
fill
|
|
style={{ objectFit: 'contain' }}
|
|
unoptimized
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|