- Wrap all AvatarSelector callback handlers in useCallback in CreateModal.tsx - Add isCancelled flag pattern to RobustCamera useEffect - Inline camera initialization to avoid stale closure issues - Add proper cleanup on component unmount - Ensure camera stream is properly stopped if component unmounts during initialization - Remove unused initializeCamera function
606 lines
20 KiB
TypeScript
606 lines
20 KiB
TypeScript
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<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false, announcement }) => {
|
|
const { subscription } = useSubscription();
|
|
const [topicInput, setTopicInput] = useState("");
|
|
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
|
|
const [speakers, setSpeakers] = useState<number>(1);
|
|
const [duration, setDuration] = useState<number>(1);
|
|
const [budgetCap, setBudgetCap] = useState<number>(50);
|
|
const [voiceFile, setVoiceFile] = useState<File | null>(null);
|
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
|
const [avatarPreviewBlobUrl, setAvatarPreviewBlobUrl] = useState<string | null>(null);
|
|
const [makingPresentable, setMakingPresentable] = useState(false);
|
|
const [enhancingTopic, setEnhancingTopic] = useState(false);
|
|
const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0);
|
|
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
|
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("Wise_Woman");
|
|
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
|
const [avatarTab, setAvatarTab] = useState(0);
|
|
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
|
|
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
|
|
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
|
|
|
|
// Enhanced topic choices state
|
|
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
|
|
const [enhancedRationales, setEnhancedRationales] = useState<string[]>([]);
|
|
const [choicesModalOpen, setChoicesModalOpen] = useState(false);
|
|
const [editedChoices, setEditedChoices] = useState<string[]>([]);
|
|
|
|
// 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<string | null>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
borderRadius: 3,
|
|
border: "1px solid rgba(15, 23, 42, 0.08)",
|
|
background: "#ffffff",
|
|
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
|
|
p: { xs: 3, md: 4.5 },
|
|
}}
|
|
>
|
|
<Stack spacing={3.5}>
|
|
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
|
|
<Box sx={{ flex: 1 }}>
|
|
<TopicUrlInput
|
|
value={topicInput}
|
|
onChange={setTopicInput}
|
|
isUrl={isUrl}
|
|
showAIDetailsButton={showAIDetailsButton}
|
|
onAIDetailsClick={handleAIDetailsClick}
|
|
placeholderIndex={placeholderIndex}
|
|
loading={enhancingTopic}
|
|
loadingMessage={enhanceTopicMessage}
|
|
estimatedCost={estimatedCost}
|
|
duration={duration}
|
|
speakers={speakers}
|
|
knobs={knobs}
|
|
/>
|
|
</Box>
|
|
|
|
<Box sx={{ width: { xs: "100%", md: "320px" } }}>
|
|
<PodcastConfiguration
|
|
duration={duration}
|
|
setDuration={setDuration}
|
|
speakers={speakers}
|
|
setSpeakers={setSpeakers}
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
|
|
<AvatarSelector
|
|
avatarTab={avatarTab}
|
|
setAvatarTab={handleTabChange}
|
|
avatarFile={avatarFile}
|
|
avatarPreview={avatarPreview}
|
|
avatarUrl={avatarUrl}
|
|
loadingBrandAvatar={loadingBrandAvatar}
|
|
handleUseBrandAvatar={handleUseBrandAvatar}
|
|
handleAvatarSelectFromLibrary={handleAvatarSelectFromLibrary}
|
|
handleAvatarChange={handleAvatarChange}
|
|
handleCameraSelfie={handleCameraSelfie}
|
|
handleRemoveAvatar={handleRemoveAvatar}
|
|
handleMakePresentable={handleMakePresentable}
|
|
makingPresentable={makingPresentable}
|
|
avatarPreviewBlobUrl={avatarPreviewBlobUrl}
|
|
brandAvatarFromDb={brandAvatarFromDb}
|
|
brandAvatarBlobUrl={brandAvatarBlobUrl}
|
|
cameraSelfieOpen={cameraSelfieOpen}
|
|
setCameraSelfieOpen={setCameraSelfieOpen}
|
|
/>
|
|
|
|
<VoiceSelector
|
|
value={selectedVoiceId}
|
|
onChange={setSelectedVoiceId}
|
|
showVoiceClone={true}
|
|
/>
|
|
|
|
<CreateActions
|
|
reset={reset}
|
|
submit={submit}
|
|
canSubmit={canSubmit}
|
|
isSubmitting={isSubmitting}
|
|
announcement={announcement}
|
|
/>
|
|
|
|
{/* Enhanced Topic Choices Modal */}
|
|
<EnhancedTopicChoicesModal
|
|
open={choicesModalOpen}
|
|
onClose={() => setChoicesModalOpen(false)}
|
|
enhancedChoices={enhancedChoices}
|
|
enhancedRationales={enhancedRationales}
|
|
onSelectChoice={handleChoiceSelection}
|
|
loading={enhancingTopic}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
};
|