1170 lines
52 KiB
TypeScript
1170 lines
52 KiB
TypeScript
/// <reference lib="dom" />
|
|
|
|
import React, { useState, ChangeEvent, useEffect } from 'react';
|
|
import { Upload, Music, Sparkles, X, Volume2, Search, MapPin, Loader2, Type, Globe, Video, Monitor, Wand2, Layers, FileText, Home, Waves, Mountain, Heart, Users, Dog, Tent, Building, CheckCircle, Lock, ChevronDown, Image as ImageIcon, ExternalLink } from 'lucide-react';
|
|
import { BusinessInfo, MusicGenre, MusicDuration, AudioMode, TTSConfig, TTSGender, TTSAge, TTSTone, TextEffect, AspectRatio, Language, TransitionEffect, PensionCategory } from '../types';
|
|
import { filterBestImages, enrichDescriptionWithReviews, extractTextEffectFromImage } from '../services/geminiService';
|
|
import { crawlNaverPlace } from '../services/naverService';
|
|
import { searchPlaceDetails, fetchPlacePhoto } from '../services/googlePlacesService';
|
|
import { useLanguage } from '../src/contexts/LanguageContext';
|
|
import OnboardingTour from './OnboardingTour';
|
|
|
|
// shadcn/ui components
|
|
import { cn } from '../src/lib/utils';
|
|
import { Button } from '../src/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../src/components/ui/card';
|
|
import { Input } from '../src/components/ui/input';
|
|
import { Label } from '../src/components/ui/label';
|
|
import { Textarea } from '../src/components/ui/textarea';
|
|
import { Badge } from '../src/components/ui/badge';
|
|
import { Separator } from '../src/components/ui/separator';
|
|
import { Switch } from '../src/components/ui/switch';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../src/components/ui/collapsible';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../src/components/ui/tooltip';
|
|
|
|
interface InputFormProps {
|
|
onSubmit: (info: BusinessInfo) => void;
|
|
isSubmitting: boolean;
|
|
}
|
|
|
|
// 국가별 장르 매핑
|
|
const GENRE_BY_LANG: Record<Language, MusicGenre[]> = {
|
|
'KO': ['Auto', 'K-Pop', 'Trot', 'Ballad', 'Hip-Hop', 'R&B', 'EDM', 'Jazz', 'Rock'],
|
|
'EN': ['Auto', 'Pop', 'Country', 'Hip-Hop', 'R&B', 'EDM', 'Jazz', 'Rock', 'Ballad'],
|
|
'JP': ['Auto', 'J-Pop', 'Enka', 'Anime', 'Rock', 'Jazz', 'Ballad', 'EDM', 'Hip-Hop'],
|
|
'CN': ['Auto', 'C-Pop', 'Mandopop', 'Traditional CN', 'Ballad', 'Hip-Hop', 'EDM', 'Jazz', 'Rock'],
|
|
'TH': ['Auto', 'T-Pop', 'Luk Thung', 'Pop', 'Hip-Hop', 'Rock', 'EDM', 'Jazz', 'Ballad'],
|
|
'VN': ['Auto', 'V-Pop', 'Bolero', 'Pop', 'Hip-Hop', 'Rock', 'EDM', 'Jazz', 'Ballad']
|
|
};
|
|
|
|
// 펜션 카테고리 옵션
|
|
const PENSION_CATEGORIES: { id: PensionCategory; icon: any; labelKey: string }[] = [
|
|
{ id: 'PoolVilla', icon: Waves, labelKey: 'catPoolVilla' },
|
|
{ id: 'OceanView', icon: Waves, labelKey: 'catOceanView' },
|
|
{ id: 'Mountain', icon: Mountain, labelKey: 'catMountain' },
|
|
{ id: 'Private', icon: Home, labelKey: 'catPrivate' },
|
|
{ id: 'Couple', icon: Heart, labelKey: 'catCouple' },
|
|
{ id: 'Family', icon: Users, labelKey: 'catFamily' },
|
|
{ id: 'Pet', icon: Dog, labelKey: 'catPet' },
|
|
{ id: 'Glamping', icon: Tent, labelKey: 'catGlamping' },
|
|
{ id: 'Traditional', icon: Building, labelKey: 'catTraditional' },
|
|
];
|
|
|
|
const FLAG_CLASSES: Record<Language, string> = {
|
|
'KO': 'fi-kr',
|
|
'EN': 'fi-us',
|
|
'JP': 'fi-jp',
|
|
'CN': 'fi-cn',
|
|
'TH': 'fi-th',
|
|
'VN': 'fi-vn'
|
|
};
|
|
|
|
const InputForm: React.FC<InputFormProps> = ({ onSubmit, isSubmitting }) => {
|
|
const { language: uiLanguage, t } = useLanguage();
|
|
|
|
// 온보딩 투어 상태
|
|
const [showTour, setShowTour] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const hasSeenTour = localStorage.getItem('castadTourCompleted');
|
|
if (!hasSeenTour) {
|
|
setTimeout(() => setShowTour(true), 1000);
|
|
}
|
|
}, []);
|
|
|
|
// 콘텐츠 생성 언어 - UI 언어와 동기화
|
|
const [contentLanguage, setContentLanguage] = useState<Language>(uiLanguage);
|
|
|
|
// Sync content language with UI language
|
|
useEffect(() => {
|
|
setContentLanguage(uiLanguage);
|
|
}, [uiLanguage]);
|
|
|
|
// 펜션 카테고리 (복수 선택)
|
|
const [pensionCategories, setPensionCategories] = useState<PensionCategory[]>([]);
|
|
|
|
// 상태 관리
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [address, setAddress] = useState('');
|
|
const [category, setCategory] = useState('');
|
|
|
|
const [images, setImages] = useState<File[]>([]);
|
|
const [previews, setPreviews] = useState<string[]>([]);
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [searchStatus, setSearchStatus] = useState('');
|
|
const [mapLink, setMapLink] = useState<string | null>(null);
|
|
|
|
const [naverUrl, setNaverUrl] = useState('');
|
|
const [isCrawling, setIsCrawling] = useState(false);
|
|
const [crawlStatus, setCrawlStatus] = useState('');
|
|
|
|
const [audioMode, setAudioMode] = useState<AudioMode>('Song');
|
|
const [musicGenre, setMusicGenre] = useState<MusicGenre>('Auto');
|
|
const [musicDuration, setMusicDuration] = useState<MusicDuration>('Short');
|
|
const [activeGenres, setActiveGenres] = useState<MusicGenre[]>(GENRE_BY_LANG['KO']);
|
|
|
|
const [ttsConfig, setTTSConfig] = useState<TTSConfig>({
|
|
gender: 'Female',
|
|
age: 'Young',
|
|
tone: 'Bright'
|
|
});
|
|
|
|
const [visualStyle, setVisualStyle] = useState<'Video' | 'Slideshow'>('Slideshow');
|
|
const [transitionEffect, setTransitionEffect] = useState<TransitionEffect>('Mix');
|
|
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('9:16');
|
|
const [textEffect, setTextEffect] = useState<TextEffect>('Cinematic');
|
|
const [customStyle, setCustomStyle] = useState<string>('');
|
|
const [isAnalyzingStyle, setIsAnalyzingStyle] = useState(false);
|
|
const [useAiImages, setUseAiImages] = useState(false);
|
|
|
|
// Collapsible states
|
|
const [openSections, setOpenSections] = useState({
|
|
category: true,
|
|
images: true,
|
|
autoSearch: false,
|
|
basicInfo: true,
|
|
settings: true,
|
|
textStyle: true,
|
|
audio: true
|
|
});
|
|
|
|
useEffect(() => {
|
|
setActiveGenres(GENRE_BY_LANG[contentLanguage]);
|
|
setMusicGenre('Auto');
|
|
}, [contentLanguage]);
|
|
|
|
const textEffects: { id: TextEffect; label: string; desc: string; previewClass?: string }[] = [
|
|
{ id: 'Cinematic', label: t('textStyle') === '자막 스타일' ? '영화 같은' : 'Cinematic', desc: 'Fade', previewClass: 'font-serif tracking-widest' },
|
|
{ id: 'Neon', label: t('textStyle') === '자막 스타일' ? '네온 사인' : 'Neon', desc: 'Glow', previewClass: 'effect-neon font-bold text-pink-500' },
|
|
{ id: 'Glitch', label: t('textStyle') === '자막 스타일' ? '글리치' : 'Glitch', desc: 'Noise', previewClass: 'effect-glitch font-mono font-bold' },
|
|
{ id: 'Typewriter', label: t('textStyle') === '자막 스타일' ? '타자기' : 'Typewriter', desc: 'Cursor', previewClass: 'font-mono border-r-2 border-white pr-1' },
|
|
{ id: 'Bold', label: t('textStyle') === '자막 스타일' ? '임팩트' : 'Bold', desc: 'Strong', previewClass: 'font-black uppercase italic' },
|
|
{ id: 'Motion', label: t('textStyle') === '자막 스타일' ? '모션' : 'Motion', desc: 'Dynamic', previewClass: 'effect-motion font-bold italic' },
|
|
{ id: 'LineReveal', label: t('textStyle') === '자막 스타일' ? '라인 등장' : 'Line Reveal', desc: 'Minimal', previewClass: 'border-t border-b border-white py-1' },
|
|
{ id: 'Boxed', label: t('textStyle') === '자막 스타일' ? '박스' : 'Boxed', desc: 'Frame', previewClass: 'border border-white p-1' },
|
|
{ id: 'Elegant', label: t('textStyle') === '자막 스타일' ? '우아함' : 'Elegant', desc: 'Classic', previewClass: 'font-serif italic' },
|
|
{ id: 'BlockReveal', label: t('textStyle') === '자막 스타일' ? '블록 등장' : 'Block Reveal', desc: 'Pop', previewClass: 'bg-white text-black px-1' },
|
|
{ id: 'Custom', label: t('textStyle') === '자막 스타일' ? '나만의 스타일' : 'Custom', desc: 'AI', previewClass: '' },
|
|
];
|
|
|
|
const transitionOptions: { id: TransitionEffect; labelKey: any }[] = [
|
|
{ id: 'Mix', labelKey: 'transMix' },
|
|
{ id: 'Zoom', labelKey: 'transZoom' },
|
|
{ id: 'Slide', labelKey: 'transSlide' },
|
|
{ id: 'Wipe', labelKey: 'transWipe' },
|
|
];
|
|
|
|
// Helper Functions
|
|
const updateImages = (newFiles: File[]) => {
|
|
const combinedFiles = [...images, ...newFiles];
|
|
const limitedFiles = combinedFiles.slice(0, 10);
|
|
setImages(limitedFiles);
|
|
|
|
const newPreviews: string[] = [];
|
|
let processed = 0;
|
|
if (limitedFiles.length === 0) {
|
|
setPreviews([]);
|
|
return;
|
|
}
|
|
|
|
limitedFiles.forEach(file => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
newPreviews.push(reader.result as string);
|
|
processed++;
|
|
if (processed === limitedFiles.length) {
|
|
setPreviews([...newPreviews]);
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) {
|
|
updateImages(Array.from(e.target.files));
|
|
}
|
|
};
|
|
|
|
const removeImage = (index: number) => {
|
|
const newImages = [...images];
|
|
newImages.splice(index, 1);
|
|
setImages(newImages);
|
|
|
|
if (newImages.length === 0) {
|
|
setPreviews([]);
|
|
} else {
|
|
const newPreviews: string[] = [];
|
|
let processed = 0;
|
|
newImages.forEach(file => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
newPreviews.push(reader.result as string);
|
|
processed++;
|
|
if (processed === newImages.length) setPreviews(newPreviews);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery) return;
|
|
setIsSearching(true);
|
|
setSearchStatus('Processing...');
|
|
setMapLink(null);
|
|
|
|
let finalQuery = searchQuery;
|
|
if (searchQuery.includes('google') && searchQuery.includes('/maps/place/')) {
|
|
try {
|
|
const match = searchQuery.match(/\/maps\/place\/([^\/]+)/);
|
|
if (match && match[1]) {
|
|
finalQuery = decodeURIComponent(match[1]).replace(/\+/g, ' ');
|
|
}
|
|
} catch (e) { console.warn("URL Error"); }
|
|
}
|
|
|
|
try {
|
|
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
|
|
if (!apiKey) throw new Error("API Key Missing");
|
|
|
|
const details = await searchPlaceDetails(finalQuery);
|
|
if (!details) {
|
|
alert("Place not found");
|
|
setIsSearching(false);
|
|
return;
|
|
}
|
|
|
|
setName(details.displayName.text);
|
|
setAddress(details.formattedAddress);
|
|
|
|
if (details.photos && details.photos.length > 0) {
|
|
setSearchStatus(`Fetching ${details.photos.length} photos...`);
|
|
const photoPromises = details.photos.slice(0, 10).map(p => fetchPlacePhoto(p.name));
|
|
const fetchedPhotos = (await Promise.all(photoPromises)).filter((f): f is File => f !== null);
|
|
|
|
updateImages(fetchedPhotos);
|
|
}
|
|
|
|
setSearchStatus('Analyzing reviews...');
|
|
let generatedDesc = details.formattedAddress;
|
|
const reviewsText = details.reviews?.map(r => r.text.text) || [];
|
|
if (reviewsText.length > 0 || details.rating) {
|
|
generatedDesc = await enrichDescriptionWithReviews(
|
|
details.displayName.text,
|
|
details.formattedAddress,
|
|
reviewsText,
|
|
details.rating || 0,
|
|
apiKey
|
|
);
|
|
}
|
|
|
|
setDescription(generatedDesc);
|
|
setSearchStatus('Done!');
|
|
setTimeout(() => setSearchStatus(''), 2000);
|
|
|
|
} catch (error) {
|
|
console.error("Search failed:", error);
|
|
alert("Failed to fetch info.");
|
|
setSearchStatus('');
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleNaverCrawl = async () => {
|
|
if (!naverUrl) return;
|
|
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
|
|
if (!apiKey) {
|
|
alert("API Key Required");
|
|
return;
|
|
}
|
|
setIsCrawling(true);
|
|
setCrawlStatus('Connecting...');
|
|
|
|
try {
|
|
const result: any = await crawlNaverPlace(naverUrl, (msg) => setCrawlStatus(msg));
|
|
if (result.name) setName(result.name);
|
|
if (result.description) setDescription(result.description);
|
|
if (result.address) setAddress(result.address);
|
|
if (result.category) setCategory(result.category);
|
|
|
|
if (result.images && result.images.length > 0) {
|
|
setCrawlStatus(`Fetched ${result.images.length} photos.`);
|
|
updateImages(result.images);
|
|
} else {
|
|
setCrawlStatus('No photos found.');
|
|
}
|
|
setTimeout(() => setCrawlStatus(''), 2000);
|
|
} catch (e: any) {
|
|
console.error("Crawl failed:", e);
|
|
alert(e.message || "Failed to crawl Naver Place.");
|
|
setCrawlStatus('');
|
|
} finally {
|
|
setIsCrawling(false);
|
|
}
|
|
};
|
|
|
|
const handleStyleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
if (!e.target.files || e.target.files.length === 0) return;
|
|
const file = e.target.files[0];
|
|
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
|
|
if (!apiKey) { alert("API Key Required"); return; }
|
|
|
|
setIsAnalyzingStyle(true);
|
|
try {
|
|
const cssCode = await extractTextEffectFromImage(file, apiKey);
|
|
let styleTag = document.getElementById('custom-text-style');
|
|
if (!styleTag) {
|
|
styleTag = document.createElement('style');
|
|
styleTag.id = 'custom-text-style';
|
|
document.head.appendChild(styleTag);
|
|
}
|
|
styleTag.textContent = cssCode;
|
|
setCustomStyle(cssCode);
|
|
setTextEffect('Custom');
|
|
alert("Style analysis complete!");
|
|
} catch (e) {
|
|
console.error("Style analysis failed:", e);
|
|
alert("Failed to analyze style.");
|
|
} finally {
|
|
setIsAnalyzingStyle(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent | null, mode: 'Full' | 'AudioOnly' = 'Full') => {
|
|
if (e) e.preventDefault();
|
|
if (name && description) {
|
|
onSubmit({
|
|
name, description, images, audioMode, musicGenre, musicDuration, ttsConfig,
|
|
textEffect, transitionEffect, visualStyle, aspectRatio, sourceUrl: naverUrl || mapLink || undefined,
|
|
customStyleCSS: customStyle, creationMode: mode, address, category,
|
|
language: contentLanguage,
|
|
useAiImages,
|
|
pensionCategories
|
|
});
|
|
} else {
|
|
alert(t('brandName') + " & " + t('brandDesc') + " required.");
|
|
}
|
|
};
|
|
|
|
// 단계별 완료 조건
|
|
const step1Complete = pensionCategories.length > 0;
|
|
const step2Complete = step1Complete;
|
|
const step3Complete = step2Complete && name.length > 0 && description.length > 0;
|
|
const step4Complete = step3Complete && contentLanguage !== undefined;
|
|
const step5Complete = step4Complete && textEffect !== undefined;
|
|
const step6Complete = step5Complete;
|
|
const canSubmit = step6Complete;
|
|
|
|
// Progress calculation
|
|
const completedSteps = [step1Complete, step2Complete, step3Complete, step4Complete, step5Complete, step6Complete].filter(Boolean).length;
|
|
const progressPercent = Math.round((completedSteps / 6) * 100);
|
|
|
|
// 온보딩 투어 스텝
|
|
const tourSteps = [
|
|
{
|
|
target: '[data-tour="step1"]',
|
|
title: '1단계: 펜션 유형 선택',
|
|
description: '운영하시는 펜션의 유형을 선택해주세요. 복수 선택 가능하며, AI가 이 정보를 바탕으로 맞춤형 콘텐츠를 생성합니다.',
|
|
placement: 'bottom' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step2"]',
|
|
title: '2단계: 사진 업로드',
|
|
description: '펜션 사진을 업로드하세요. 사진이 부족하면 AI가 자동으로 생성할 수도 있습니다. (선택사항)',
|
|
placement: 'bottom' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step3"]',
|
|
title: '3단계: 정보 자동 입력',
|
|
description: '네이버 플레이스 URL 또는 Google Maps에서 검색하여 펜션 정보를 자동으로 가져올 수 있습니다.',
|
|
placement: 'bottom' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step4"]',
|
|
title: '4단계: 기본 정보 입력',
|
|
description: '펜션 이름과 설명을 입력해주세요. 자동 입력을 사용했다면 이미 채워져 있을 거예요!',
|
|
placement: 'bottom' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step5"]',
|
|
title: '5단계: 언어 및 영상 설정',
|
|
description: '영상 생성 언어, 비율(인스타/유튜브), 비주얼 스타일을 선택하세요.',
|
|
placement: 'left' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step6"]',
|
|
title: '6단계: 자막 스타일',
|
|
description: '영상에 표시될 자막의 스타일을 선택하세요. 다양한 효과를 미리 볼 수 있습니다.',
|
|
placement: 'top' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="step7"]',
|
|
title: '7단계: 오디오 설정',
|
|
description: '노래, BGM, 성우 중 선택하고, 장르나 음성 설정을 커스터마이징하세요.',
|
|
placement: 'top' as const,
|
|
},
|
|
{
|
|
target: '[data-tour="submit"]',
|
|
title: '마지막: 영상 생성!',
|
|
description: '모든 설정이 완료되면 이 버튼을 눌러 AI 영상 생성을 시작하세요!',
|
|
placement: 'top' as const,
|
|
},
|
|
];
|
|
|
|
const handleTourComplete = () => {
|
|
localStorage.setItem('castadTourCompleted', 'true');
|
|
setShowTour(false);
|
|
};
|
|
|
|
const handleTourSkip = () => {
|
|
localStorage.setItem('castadTourCompleted', 'true');
|
|
setShowTour(false);
|
|
};
|
|
|
|
// Step indicator component
|
|
const StepIndicator = ({ step, completed, active, locked }: { step: number; completed: boolean; active: boolean; locked: boolean }) => (
|
|
<div className={cn(
|
|
"w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all",
|
|
completed && "bg-green-600 text-white",
|
|
active && !completed && "bg-primary text-primary-foreground",
|
|
locked && "bg-muted text-muted-foreground",
|
|
!completed && !active && !locked && "bg-muted text-muted-foreground"
|
|
)}>
|
|
{locked ? <Lock className="w-3.5 h-3.5" /> : completed ? <CheckCircle className="w-4 h-4" /> : step}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{showTour && (
|
|
<OnboardingTour
|
|
steps={tourSteps}
|
|
onComplete={handleTourComplete}
|
|
onSkip={handleTourSkip}
|
|
/>
|
|
)}
|
|
|
|
<div className="w-full pb-20 pt-6 relative">
|
|
|
|
{/* Header */}
|
|
<div className="text-center mb-8">
|
|
<h1 className="heading-2 gradient-text mb-2">
|
|
{t('appTitle')}
|
|
</h1>
|
|
<p className="text-muted-foreground">{t('appSubtitle')}</p>
|
|
|
|
{/* Progress indicator */}
|
|
<div className="mt-6 max-w-md mx-auto">
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-2">
|
|
<span>{t('textStyle') === '자막 스타일' ? '진행률' : 'Progress'}</span>
|
|
<span className="font-medium">{progressPercent}%</span>
|
|
</div>
|
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-primary to-accent transition-all duration-500"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={(e) => handleSubmit(e, 'Full')} className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
|
|
|
{/* [LEFT COLUMN] */}
|
|
<div className="lg:col-span-5 space-y-4 lg:sticky lg:top-24">
|
|
|
|
{/* STEP 1: Pension Category */}
|
|
<Card
|
|
data-tour="step1"
|
|
className={cn(
|
|
"transition-all",
|
|
step1Complete && "border-green-500/50 bg-green-500/5"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={1} completed={step1Complete} active={!step1Complete} locked={false} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">{t('pensionCategory')}</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{t('textStyle') === '자막 스타일' ? '복수 선택 가능' : 'Multiple selection allowed'}
|
|
</CardDescription>
|
|
</div>
|
|
{step1Complete && <Badge variant="outline" className="text-green-500 border-green-500/50">{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}</Badge>}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{PENSION_CATEGORIES.map((cat) => {
|
|
const isSelected = pensionCategories.includes(cat.id);
|
|
return (
|
|
<button
|
|
key={cat.id}
|
|
type="button"
|
|
onClick={() => {
|
|
if (isSelected) {
|
|
setPensionCategories(pensionCategories.filter(c => c !== cat.id));
|
|
} else {
|
|
setPensionCategories([...pensionCategories, cat.id]);
|
|
}
|
|
}}
|
|
className={cn(
|
|
"flex flex-col items-center gap-1.5 p-3 rounded-lg border text-xs font-medium transition-all",
|
|
isSelected
|
|
? "bg-primary/10 border-primary text-foreground shadow-sm"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted hover:border-muted-foreground/30"
|
|
)}
|
|
>
|
|
<cat.icon className={cn("w-5 h-5", isSelected ? "text-primary" : "text-muted-foreground")} />
|
|
<span className="truncate w-full text-center">{t(cat.labelKey as any)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* STEP 2: Image Upload */}
|
|
<Card
|
|
data-tour="step2"
|
|
className={cn(
|
|
"transition-all",
|
|
!step1Complete && "opacity-60"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={2} completed={false} active={step1Complete} locked={!step1Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">{t('uploadTitle')}</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{t('textStyle') === '자막 스타일' ? '최대 10장 (선택사항)' : 'Up to 10 images (optional)'}
|
|
</CardDescription>
|
|
</div>
|
|
{previews.length > 0 && (
|
|
<Badge variant="secondary">{previews.length}/10</Badge>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="relative group">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleImageChange}
|
|
className="hidden"
|
|
id="image-upload"
|
|
disabled={!step1Complete}
|
|
/>
|
|
<label
|
|
htmlFor="image-upload"
|
|
className={cn(
|
|
"block w-full aspect-video rounded-lg border-2 border-dashed transition-all cursor-pointer",
|
|
"flex flex-col items-center justify-center",
|
|
previews.length > 0
|
|
? "border-primary/50 bg-primary/5"
|
|
: "border-muted-foreground/30 hover:border-primary hover:bg-muted/50",
|
|
!step1Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
<Upload className="w-8 h-8 text-muted-foreground mb-2 group-hover:text-primary transition-colors" />
|
|
<p className="text-sm font-medium text-foreground">{t('uploadDesc')}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">{t('uploadMax')}</p>
|
|
</label>
|
|
</div>
|
|
|
|
{previews.length > 0 && (
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{previews.map((src, idx) => (
|
|
<div key={idx} className="relative aspect-square rounded-md overflow-hidden border border-border group">
|
|
<img src={src} alt="" className="w-full h-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={() => removeImage(idx)}
|
|
className="absolute top-1 right-1 p-1 bg-background/80 rounded-full opacity-0 group-hover:opacity-100 hover:bg-destructive transition-all"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Image Option */}
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 border border-border">
|
|
<div className="flex items-center gap-3">
|
|
<Sparkles className={cn("w-5 h-5", useAiImages ? "text-primary" : "text-muted-foreground")} />
|
|
<div>
|
|
<p className="text-sm font-medium">{t('aiImageOption')}</p>
|
|
<p className="text-xs text-muted-foreground">{t('aiImageDesc')}</p>
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={useAiImages}
|
|
onCheckedChange={setUseAiImages}
|
|
disabled={!step1Complete}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* STEP 3: Auto Data Search */}
|
|
<Collapsible
|
|
open={openSections.autoSearch}
|
|
onOpenChange={(open) => setOpenSections({...openSections, autoSearch: open})}
|
|
>
|
|
<Card
|
|
data-tour="step3"
|
|
className={cn(
|
|
"transition-all",
|
|
!step2Complete && "opacity-60"
|
|
)}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<CardHeader className="pb-3 cursor-pointer hover:bg-muted/30 transition-colors rounded-t-lg">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={3} completed={false} active={step2Complete} locked={!step2Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">{t('autoInfoTitle')}</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{t('textStyle') === '자막 스타일' ? '네이버/구글에서 자동 가져오기' : 'Auto-fetch from Naver/Google'}
|
|
</CardDescription>
|
|
</div>
|
|
<ChevronDown className={cn("w-4 h-4 transition-transform", openSections.autoSearch && "rotate-180")} />
|
|
</div>
|
|
</CardHeader>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<CardContent className="space-y-3 pt-0">
|
|
{/* Naver */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<span className="w-4 h-4 rounded bg-[#03C75A] flex items-center justify-center text-white text-[8px] font-bold">N</span>
|
|
{t('naverPlace')}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="text"
|
|
value={naverUrl}
|
|
onChange={(e) => setNaverUrl(e.target.value)}
|
|
placeholder="https://naver.me/..."
|
|
disabled={!step2Complete}
|
|
className="flex-1 h-9 text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={handleNaverCrawl}
|
|
disabled={isCrawling || !naverUrl || !step2Complete}
|
|
size="sm"
|
|
className="bg-[#03C75A] hover:bg-[#02b351]"
|
|
>
|
|
{isCrawling ? <Loader2 className="w-4 h-4 animate-spin" /> : t('fetchBtn')}
|
|
</Button>
|
|
</div>
|
|
{crawlStatus && <p className="text-xs text-muted-foreground animate-pulse">{crawlStatus}</p>}
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Separator className="my-2" />
|
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
|
{t('textStyle') === '자막 스타일' ? '또는' : 'OR'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Google */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Search className="w-3 h-3" />
|
|
{t('googleSearch')}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleSearch())}
|
|
placeholder="Search place name..."
|
|
disabled={!step2Complete}
|
|
className="flex-1 h-9 text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSearch}
|
|
disabled={isSearching || !searchQuery || !step2Complete}
|
|
size="sm"
|
|
>
|
|
{isSearching ? <Loader2 className="w-4 h-4 animate-spin" /> : t('searchBtn')}
|
|
</Button>
|
|
</div>
|
|
{searchStatus && <p className="text-xs text-primary animate-pulse">{searchStatus}</p>}
|
|
</div>
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Card>
|
|
</Collapsible>
|
|
|
|
{/* STEP 4: Basic Info */}
|
|
<Card
|
|
data-tour="step4"
|
|
className={cn(
|
|
"transition-all",
|
|
!step2Complete && "opacity-60",
|
|
step3Complete && "border-green-500/50 bg-green-500/5"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={4} completed={step3Complete} active={step2Complete && !step3Complete} locked={!step2Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">
|
|
{t('textStyle') === '자막 스타일' ? '기본 정보' : 'Basic Info'}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{t('textStyle') === '자막 스타일' ? '펜션 이름과 설명을 입력하세요' : 'Enter pension name and description'}
|
|
</CardDescription>
|
|
</div>
|
|
{step3Complete && <Badge variant="outline" className="text-green-500 border-green-500/50">{t('textStyle') === '자막 스타일' ? '완료' : 'Done'}</Badge>}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name" className="text-xs font-medium">{t('brandName')}</Label>
|
|
<Input
|
|
id="name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="ex: Galaxy Pension"
|
|
disabled={!step2Complete}
|
|
className="h-11 text-base font-medium"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description" className="text-xs font-medium">{t('brandDesc')}</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('textStyle') === '자막 스타일' ? '펜션의 특징, 분위기, 시설 등을 설명해주세요...' : 'Describe features, atmosphere, facilities...'}
|
|
rows={4}
|
|
disabled={!step2Complete}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* [RIGHT COLUMN] */}
|
|
<div className="lg:col-span-7 space-y-4">
|
|
|
|
{/* STEP 5: Content Language & Settings */}
|
|
<Card
|
|
data-tour="step5"
|
|
className={cn(
|
|
"transition-all",
|
|
!step3Complete && "opacity-60"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={5} completed={step4Complete} active={step3Complete && !step4Complete} locked={!step3Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base">
|
|
{t('textStyle') === '자막 스타일' ? '언어 및 영상 설정' : 'Language & Video Settings'}
|
|
</CardTitle>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Content Language Select */}
|
|
<div className="space-y-3">
|
|
<Label className="text-xs font-medium flex items-center gap-2">
|
|
<Globe className="w-4 h-4" />
|
|
{t('textStyle') === '자막 스타일' ? '생성 언어 (결과물)' : 'Output Language'}
|
|
</Label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{(['KO', 'EN', 'JP', 'CN', 'TH', 'VN'] as Language[]).map((lang) => (
|
|
<button
|
|
key={lang}
|
|
type="button"
|
|
onClick={() => setContentLanguage(lang)}
|
|
disabled={!step3Complete}
|
|
className={cn(
|
|
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all",
|
|
contentLanguage === lang
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step3Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
<span className={cn("fi rounded", FLAG_CLASSES[lang])}></span>
|
|
{lang}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Ratio */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
<Monitor className="w-3.5 h-3.5" /> {t('ratio')}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
{(['16:9', '9:16'] as AspectRatio[]).map((r) => (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
onClick={() => setAspectRatio(r)}
|
|
disabled={!step3Complete}
|
|
className={cn(
|
|
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
|
|
aspectRatio === r
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step3Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visual Style */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium flex items-center gap-1">
|
|
<Video className="w-3.5 h-3.5" /> {t('visualStyle')}
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisualStyle('Slideshow')}
|
|
disabled={!step3Complete}
|
|
className={cn(
|
|
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
|
|
visualStyle === 'Slideshow'
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step3Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{t('visualSlide')}
|
|
</button>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => { if(prompt('Admin Password:')==='1234') setVisualStyle('Video'); }}
|
|
disabled={!step3Complete}
|
|
className={cn(
|
|
"flex-1 py-2.5 rounded-lg border text-sm font-medium transition-all",
|
|
visualStyle === 'Video'
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step3Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{t('visualVideo')}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Admin only</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* STEP 6: Text & Transition */}
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
<Card
|
|
data-tour="step6"
|
|
className={cn(
|
|
"transition-all",
|
|
!step4Complete && "opacity-60"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={6} completed={step5Complete} active={step4Complete && !step5Complete} locked={!step4Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Type className="w-4 h-4" /> {t('textStyle')}
|
|
</CardTitle>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{textEffects.map((effect) => (
|
|
<button
|
|
key={effect.id}
|
|
type="button"
|
|
onClick={() => setTextEffect(effect.id)}
|
|
disabled={!step4Complete}
|
|
className={cn(
|
|
"p-2 rounded-lg text-left border transition-all relative overflow-hidden",
|
|
textEffect === effect.id
|
|
? "bg-primary/10 border-primary text-foreground ring-1 ring-primary"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step4Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
<div className="text-[10px] font-medium mb-1 truncate">{effect.label}</div>
|
|
<div className={cn("text-sm leading-none", effect.previewClass || '')}>Aa</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<label className={cn(
|
|
"flex items-center justify-center gap-2 w-full p-2.5 border border-dashed rounded-lg cursor-pointer transition-all text-xs",
|
|
"border-muted-foreground/30 hover:border-primary hover:bg-primary/5",
|
|
!step4Complete && "pointer-events-none opacity-50"
|
|
)}>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleStyleImageUpload}
|
|
disabled={isAnalyzingStyle || !step4Complete}
|
|
/>
|
|
{isAnalyzingStyle
|
|
? <><Loader2 className="w-3 h-3 animate-spin" /> Analyzing...</>
|
|
: <><Sparkles className="w-3 h-3 text-yellow-500" /> {t('customStyle')}</>
|
|
}
|
|
</label>
|
|
|
|
{visualStyle === 'Slideshow' && (
|
|
<div className="pt-2">
|
|
<Label className="text-xs font-medium flex items-center gap-2 mb-2">
|
|
<Layers className="w-3.5 h-3.5" /> {t('transitionEffect')}
|
|
</Label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{transitionOptions.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
onClick={() => setTransitionEffect(opt.id)}
|
|
disabled={!step4Complete}
|
|
className={cn(
|
|
"p-2 rounded-lg text-center border text-xs font-medium transition-all",
|
|
transitionEffect === opt.id
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "bg-card border-border text-muted-foreground hover:bg-muted",
|
|
!step4Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* STEP 7: Audio Settings */}
|
|
<Card
|
|
data-tour="step7"
|
|
className={cn(
|
|
"transition-all",
|
|
!step5Complete && "opacity-60"
|
|
)}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-3">
|
|
<StepIndicator step={7} completed={step6Complete} active={step5Complete && !step6Complete} locked={!step5Complete} />
|
|
<div className="flex-1">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Volume2 className="w-4 h-4" /> {t('audioSettings')}
|
|
</CardTitle>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* Audio Mode Tabs */}
|
|
<div className="flex bg-muted p-1 rounded-lg mb-4">
|
|
{(['Song', 'Instrumental', 'Narration'] as AudioMode[]).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
onClick={() => setAudioMode(mode)}
|
|
disabled={!step5Complete}
|
|
className={cn(
|
|
"flex-1 py-2 rounded-md text-xs font-medium transition-all",
|
|
audioMode === mode
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
!step5Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{mode === 'Song' ? t('song') : mode === 'Instrumental' ? t('bgm') : t('narration')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{audioMode === 'Narration' ? (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px] text-muted-foreground">{t('gender')}</Label>
|
|
<div className="flex bg-muted rounded-lg p-1">
|
|
{(['Female', 'Male'] as TTSGender[]).map(g => (
|
|
<button
|
|
key={g}
|
|
type="button"
|
|
onClick={() => setTTSConfig({...ttsConfig, gender: g})}
|
|
disabled={!step5Complete}
|
|
className={cn(
|
|
"flex-1 py-1.5 rounded text-xs font-medium transition-all",
|
|
ttsConfig.gender === g
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground"
|
|
)}
|
|
>
|
|
{g === 'Female' ? 'F' : 'M'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px] text-muted-foreground">{t('age')}</Label>
|
|
<div className="flex bg-muted rounded-lg p-1">
|
|
{(['Young', 'Middle'] as TTSAge[]).map(a => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
onClick={() => setTTSConfig({...ttsConfig, age: a})}
|
|
disabled={!step5Complete}
|
|
className={cn(
|
|
"flex-1 py-1.5 rounded text-xs font-medium transition-all",
|
|
ttsConfig.age === a
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground"
|
|
)}
|
|
>
|
|
{a}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px] text-muted-foreground">{t('tone')}</Label>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{(['Bright', 'Calm', 'Energetic', 'Professional'] as TTSTone[]).map(tone => (
|
|
<button
|
|
key={tone}
|
|
type="button"
|
|
onClick={() => setTTSConfig({...ttsConfig, tone})}
|
|
disabled={!step5Complete}
|
|
className={cn(
|
|
"py-1.5 rounded text-[10px] border font-medium transition-all",
|
|
ttsConfig.tone === tone
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "border-border bg-card text-muted-foreground hover:bg-muted"
|
|
)}
|
|
>
|
|
{tone}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">Genre</Label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{activeGenres.map((g) => (
|
|
<button
|
|
key={g}
|
|
type="button"
|
|
onClick={() => setMusicGenre(g)}
|
|
disabled={!step5Complete}
|
|
className={cn(
|
|
"p-2.5 rounded-lg text-sm font-medium border transition-all",
|
|
musicGenre === g
|
|
? "bg-primary/10 border-primary text-foreground"
|
|
: "border-border bg-card text-muted-foreground hover:bg-muted",
|
|
!step5Complete && "pointer-events-none"
|
|
)}
|
|
>
|
|
{g === 'Auto'
|
|
? <span className="flex items-center justify-center gap-1"><Sparkles className="w-3.5 h-3.5" /> Auto</span>
|
|
: g
|
|
}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Submit Buttons */}
|
|
<div data-tour="submit" className="flex flex-col sm:flex-row gap-3 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="lg"
|
|
onClick={() => handleSubmit(null, 'AudioOnly')}
|
|
disabled={isSubmitting || !canSubmit}
|
|
className="flex-1 h-14"
|
|
>
|
|
{isSubmitting ? (
|
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
) : canSubmit ? (
|
|
<Music className="w-5 h-5 mr-2" />
|
|
) : (
|
|
<Lock className="w-5 h-5 mr-2" />
|
|
)}
|
|
{t('submitAudio')}
|
|
</Button>
|
|
|
|
<Button
|
|
type="submit"
|
|
size="lg"
|
|
disabled={isSubmitting || !canSubmit}
|
|
className={cn(
|
|
"flex-[2] h-14 text-lg font-bold",
|
|
canSubmit && "bg-gradient-to-r from-primary to-accent hover:opacity-90"
|
|
)}
|
|
>
|
|
{isSubmitting ? (
|
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
|
) : canSubmit ? (
|
|
<Sparkles className="w-6 h-6 mr-2" />
|
|
) : (
|
|
<Lock className="w-6 h-6 mr-2" />
|
|
)}
|
|
{isSubmitting
|
|
? t('processing')
|
|
: canSubmit
|
|
? t('submitVideo')
|
|
: (t('textStyle') === '자막 스타일' ? '모든 단계를 완료하세요' : 'Complete all steps')
|
|
}
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default InputForm;
|