import React, { useState, useEffect, useMemo } from "react"; import { Stack, Paper, Box } from "@mui/material"; import { CreateProjectPayload, Knobs } from "./types"; import { useSubscription } from "../../contexts/SubscriptionContext"; import { podcastApi } from "../../services/podcastApi"; import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl"; import { getLatestBrandAvatar } from "../../api/brandAssets"; import { VoiceSelector } from "../shared/VoiceSelector"; // Imported Components import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput"; import { PodcastConfiguration } from "./CreateStep/PodcastConfiguration"; import { AvatarSelector } from "./CreateStep/AvatarSelector"; import { CreateActions } from "./CreateStep/CreateActions"; import { EnhancedTopicChoicesModal } from "./EnhancedTopicChoicesModal"; const ENHANCE_TOPIC_PROGRESS_MESSAGES = [ "Analyzing your topic idea...", "Enhancing clarity and hook...", "Aligning language for podcast listeners...", ]; interface CreateModalProps { onCreate: (payload: CreateProjectPayload) => void; open: boolean; defaultKnobs: Knobs; isSubmitting?: boolean; announcement?: string; } export const CreateModal: React.FC = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => { const { subscription } = useSubscription(); const [topicInput, setTopicInput] = useState(""); const [showAIDetailsButton, setShowAIDetailsButton] = useState(false); const [speakers, setSpeakers] = useState(1); const [duration, setDuration] = useState(1); const [budgetCap, setBudgetCap] = useState(50); const [voiceFile, setVoiceFile] = useState(null); const [avatarFile, setAvatarFile] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); const [avatarUrl, setAvatarUrl] = useState(null); const [avatarPreviewBlobUrl, setAvatarPreviewBlobUrl] = useState(null); const [makingPresentable, setMakingPresentable] = useState(false); const [enhancingTopic, setEnhancingTopic] = useState(false); const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0); const [knobs, setKnobs] = useState({ ...defaultKnobs }); const [selectedVoiceId, setSelectedVoiceId] = useState("Wise_Woman"); const [placeholderIndex, setPlaceholderIndex] = useState(0); const [avatarTab, setAvatarTab] = useState(0); const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false); const [brandAvatarFromDb, setBrandAvatarFromDb] = useState(null); const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false); // Enhanced topic choices state const [enhancedChoices, setEnhancedChoices] = useState([]); const [enhancedRationales, setEnhancedRationales] = useState([]); const [choicesModalOpen, setChoicesModalOpen] = useState(false); const [editedChoices, setEditedChoices] = useState([]); // Rotate placeholder every 3 seconds useEffect(() => { if (!topicInput) { const interval = setInterval(() => { setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length); }, 3000); return () => clearInterval(interval); } }, [topicInput]); // Fetch Brand Avatar on mount but don't select it useEffect(() => { const fetchBrandAvatar = async () => { try { setLoadingBrandAvatar(true); const result = await getLatestBrandAvatar(); if (result.success && result.image_url) { setBrandAvatarFromDb(result.image_url); } } catch (error) { console.error("Failed to pre-fetch brand avatar:", error); } finally { setLoadingBrandAvatar(false); } }; fetchBrandAvatar(); }, []); useEffect(() => { if (!avatarPreview) { setAvatarPreviewBlobUrl(null); return; } if (avatarPreview.startsWith("data:") || avatarPreview.startsWith("blob:")) { setAvatarPreviewBlobUrl(null); return; } const isInternal = avatarPreview.includes("/api/podcast/") || avatarPreview.includes("/api/youtube/") || avatarPreview.includes("/api/story/") || (avatarPreview.startsWith("/") && !avatarPreview.startsWith("//")); if (!isInternal) { setAvatarPreviewBlobUrl(null); return; } let isMounted = true; const currentPreview = avatarPreview; const loadBlob = async () => { try { const blobUrl = await fetchMediaBlobUrl(currentPreview); if (!isMounted || avatarPreview !== currentPreview) { if (blobUrl && blobUrl.startsWith("blob:")) { URL.revokeObjectURL(blobUrl); } return; } setAvatarPreviewBlobUrl((prev) => { if (prev && prev !== blobUrl && prev.startsWith("blob:")) { URL.revokeObjectURL(prev); } return blobUrl; }); } catch { if (isMounted && avatarPreview === currentPreview) { setAvatarPreviewBlobUrl(null); } } }; loadBlob(); return () => { isMounted = false; setAvatarPreviewBlobUrl((prev) => { if (prev && prev.startsWith("blob:")) { URL.revokeObjectURL(prev); } return null; }); }; }, [avatarPreview]); // Handle blob URL for the potential brand avatar preview (not selected yet) const [brandAvatarBlobUrl, setBrandAvatarBlobUrl] = useState(null); useEffect(() => { if (!brandAvatarFromDb) { setBrandAvatarBlobUrl(null); return; } let isMounted = true; const loadBrandBlob = async () => { try { // Clear cache for this URL to ensure fresh data if (brandAvatarFromDb) { clearMediaCache(brandAvatarFromDb); } const blobUrl = await fetchMediaBlobUrl(brandAvatarFromDb); if (isMounted) setBrandAvatarBlobUrl(blobUrl); } catch (err) { console.error("Failed to load brand avatar blob:", err); } }; loadBrandBlob(); return () => { isMounted = false; if (brandAvatarBlobUrl && brandAvatarBlobUrl.startsWith("blob:")) { URL.revokeObjectURL(brandAvatarBlobUrl); } }; }, [brandAvatarFromDb]); // Ensure duration and speakers are within limits useEffect(() => { if (duration > 10) { setDuration(10); } if (speakers > 2) { setSpeakers(2); } }, [duration, speakers]); // URL detection helper const detectUrl = (text: string): boolean => { const urlRegex = /(https?:\/\/[^\s]+)/g; return urlRegex.test(text); }; const isUrl = useMemo(() => detectUrl(topicInput), [topicInput]); const enhanceTopicMessage = enhancingTopic ? ENHANCE_TOPIC_PROGRESS_MESSAGES[enhanceTopicProgressIndex] : undefined; useEffect(() => { if (!enhancingTopic) { setEnhanceTopicProgressIndex(0); return; } const interval = setInterval(() => { setEnhanceTopicProgressIndex((prev) => (prev + 1) % ENHANCE_TOPIC_PROGRESS_MESSAGES.length); }, 1200); return () => clearInterval(interval); }, [enhancingTopic]); // Handle AI Details button click const handleAIDetailsClick = async () => { if (!topicInput.trim() || enhancingTopic) return; try { setEnhancingTopic(true); // We pass the current Bible context if we have it (unlikely here as it's generated in analysis) // But the backend will generate it from onboarding data if missing const result = await podcastApi.enhanceIdea({ idea: topicInput, }); if (result.enhanced_ideas && result.enhanced_ideas.length === 3) { setEnhancedChoices(result.enhanced_ideas); setEnhancedRationales(result.rationales || []); setEditedChoices(result.enhanced_ideas); // Initialize editable versions setChoicesModalOpen(true); } } catch (error) { console.error("Failed to enhance idea with AI:", error); } finally { setEnhancingTopic(false); } }; // Handle enhanced topic choice selection const handleChoiceSelection = (selectedIndex: number, editedChoice: string) => { const selectedTopic = editedChoice; setTopicInput(selectedTopic); setChoicesModalOpen(false); // Reset choices state setEnhancedChoices([]); setEnhancedRationales([]); setEditedChoices([]); }; // Show AI details button when user starts typing (and it's not a URL) useEffect(() => { setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl); }, [topicInput, isUrl]); // Calculate estimated cost const estimatedCost = useMemo(() => { const chars = Math.max(1000, duration * 900); // ~900 chars per minute const secs = duration * 60; const ttsCost = (chars / 1000) * 0.05; const avatarCost = speakers * 0.15; const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03; const videoCost = secs * videoRate; const researchCost = 0.3; // Fixed research cost return { ttsCost: +ttsCost.toFixed(2), avatarCost: +avatarCost.toFixed(2), videoCost: +videoCost.toFixed(2), researchCost: +researchCost.toFixed(2), total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2), }; }, [duration, speakers, knobs.bitrate, knobs.scene_length_target]); // Check if avatar is present (from any source: upload, brand avatar, or generated) const hasAvatar = Boolean( avatarFile || // User uploaded an image avatarUrl || // Already processed avatar URL avatarPreview || // Avatar preview available brandAvatarFromDb || // Brand avatar from database brandAvatarBlobUrl // Brand avatar blob URL ); // Check if all required inputs are provided const hasTopic = Boolean(topicInput.trim()); const hasVoice = Boolean(selectedVoiceId); const hasDuration = Boolean(duration > 0 && duration <= 10); const hasSpeakers = Boolean(speakers >= 1 && speakers <= 2); const canSubmit = Boolean(hasTopic && hasAvatar && hasVoice && hasDuration && hasSpeakers); const submit = async () => { if (!canSubmit || isSubmitting) return; // Determine if input is idea or URL // For URL, we extract the first URL found or use the whole string if it's a direct URL let finalIdea = ""; let finalUrl = ""; if (isUrl) { // Simple extraction: if the input contains a URL, we treat the input as the URL (or extract it) // For now, let's assume the user pasted a URL. // If there's mixed text, we might want to just send the whole thing as 'url' if the backend handles extraction, // or extract it here. // The previous logic used specific 'url' state. const urlMatch = topicInput.match(/(https?:\/\/[^\s]+)/); if (urlMatch) { finalUrl = urlMatch[0]; } else { // Fallback finalUrl = topicInput; } } else { finalIdea = topicInput; } // If avatar was uploaded but not yet uploaded to server, upload it now let finalAvatarUrl: string | null = avatarUrl; if (avatarFile && !avatarUrl) { try { const { podcastApi } = await import("../../services/podcastApi"); const uploadResult = await podcastApi.uploadAvatar(avatarFile); finalAvatarUrl = uploadResult.avatar_url; } catch (error) { console.error('Avatar upload failed:', error); // Continue without avatar } } // Include selected voice in knobs const finalKnobs = { ...knobs, voice_id: selectedVoiceId, }; onCreate({ ideaOrUrl: finalUrl || finalIdea, speakers, duration, knobs: finalKnobs, budgetCap, files: { voiceFile, avatarFile }, avatarUrl: finalAvatarUrl, }); }; const reset = () => { setTopicInput(""); setSpeakers(1); setDuration(1); setBudgetCap(50); setVoiceFile(null); setAvatarFile(null); setAvatarPreview(null); setAvatarUrl(null); setMakingPresentable(false); setEnhancingTopic(false); setEnhanceTopicProgressIndex(0); setKnobs({ ...defaultKnobs }); setSelectedVoiceId("Wise_Woman"); setPlaceholderIndex(0); }; const handleAvatarSelectFromLibrary = React.useCallback((url: string) => { setAvatarFile(null); setAvatarPreview(url); setAvatarUrl(url); }, []); const handleAvatarChange = React.useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { // Validate file type if (!file.type.startsWith('image/')) { console.error('Please select an image file'); return; } // Validate file size (e.g., max 5MB) if (file.size > 5 * 1024 * 1024) { console.error('Image file size must be less than 5MB'); return; } setAvatarFile(file); // Create preview const reader = new FileReader(); reader.onloadend = () => { setAvatarPreview(reader.result as string); }; reader.readAsDataURL(file); // Upload image immediately to get URL (for "Make Presentable" feature) try { const { podcastApi } = await import("../../services/podcastApi"); const uploadResult = await podcastApi.uploadAvatar(file); setAvatarUrl(uploadResult.avatar_url); } catch (error) { console.error('Avatar upload failed:', error); // Continue with local preview - upload will happen on submit } } }, []); const handleCameraSelfie = React.useCallback(async (imageDataUrl: string) => { try { // Convert dataURL to File object const response = await fetch(imageDataUrl); const blob = await response.blob(); const file = new File([blob], 'selfie.jpg', { type: 'image/jpeg' }); // Set the file and preview setAvatarFile(file); setAvatarPreview(imageDataUrl); // Upload image immediately to get URL (for "Make Presentable" feature) try { const { podcastApi } = await import("../../services/podcastApi"); const uploadResult = await podcastApi.uploadAvatar(file); setAvatarUrl(uploadResult.avatar_url); } catch (error) { console.error('Avatar upload failed:', error); // Continue with local preview - upload will happen on submit } // Close camera dialog setCameraSelfieOpen(false); } catch (error) { console.error('Failed to process selfie:', error); } }, []); const handleRemoveAvatar = React.useCallback(() => { setAvatarFile(null); setAvatarPreview(null); setAvatarUrl(null); if (avatarPreviewBlobUrl && avatarPreviewBlobUrl.startsWith("blob:")) { URL.revokeObjectURL(avatarPreviewBlobUrl); } setAvatarPreviewBlobUrl(null); setMakingPresentable(false); }, [avatarPreviewBlobUrl]); const handleUseBrandAvatar = React.useCallback(async () => { if (brandAvatarFromDb) { setAvatarFile(null); setAvatarPreview(brandAvatarFromDb); setAvatarUrl(brandAvatarFromDb); // Ensure the blob URL is set for the preview logic if (brandAvatarBlobUrl) { setAvatarPreviewBlobUrl(brandAvatarBlobUrl); } return; } if (loadingBrandAvatar) return; try { setLoadingBrandAvatar(true); const result = await getLatestBrandAvatar(); if (result.success && result.image_url) { setAvatarFile(null); setAvatarPreview(result.image_url); setAvatarUrl(result.image_url); setBrandAvatarFromDb(result.image_url); } else { console.error(result.error || result.message || "No brand avatar found"); } } catch (error) { console.error("Failed to load brand avatar:", error); } finally { setLoadingBrandAvatar(false); } }, [brandAvatarFromDb, brandAvatarBlobUrl, loadingBrandAvatar]); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setAvatarTab(newValue); if (newValue === 0) { // Switch to brand avatar tab - it's already pre-fetched on mount } else if (newValue === 1) { // Asset Library tab - clear current selection so user must choose setAvatarUrl(null); setAvatarPreview(null); setAvatarFile(null); } else if (newValue === 2) { // Upload tab - clear if no file uploaded yet to show dropzone clean state if (!avatarFile) { setAvatarUrl(null); setAvatarPreview(null); } } }; // Initialize with Brand Avatar removed - user must explicitly choose or it's AI generated useEffect(() => { // We used to auto-load here, but now we leave it empty to allow AI generation later // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleMakePresentable = React.useCallback(async () => { if (!avatarUrl || makingPresentable) return; try { setMakingPresentable(true); const { podcastApi } = await import("../../services/podcastApi"); const result = await podcastApi.makeAvatarPresentable(avatarUrl); // Fetch the transformed image as blob to display const { aiApiClient } = await import("../../api/client"); const response = await aiApiClient.get(result.avatar_url, { responseType: 'blob' }); const blobUrl = URL.createObjectURL(response.data); setAvatarPreview(blobUrl); setAvatarUrl(result.avatar_url); } catch (error) { console.error('Failed to make avatar presentable:', error); // Could show error message to user } finally { setMakingPresentable(false); } }, [avatarUrl, makingPresentable]); return ( {/* Enhanced Topic Choices Modal */} setChoicesModalOpen(false)} enhancedChoices={enhancedChoices} enhancedRationales={enhancedRationales} onSelectChoice={handleChoiceSelection} loading={enhancingTopic} /> ); };