feat(podcast): add pre-estimate endpoint, enhance cost estimator with multi-model support, cleanup alpha pricing seeding

- Add POST /podcast/pre-estimate endpoint for cost estimation before analysis
- Enhance cost_estimator.py with multi-model support (gemini, audio, voice clone, image, video)
- Add detailed cost breakdown (llm, audio, media costs + per-phase breakdown)
- Remove redundant pricing seeding from init_alpha_subscription_tiers.py
- Add SSOT pricing via PricingService.initialize_default_pricing()
- Update TopicUrlInput tooltip to show estimate details
- Add debug logging for pricing seeding and pre-estimate
- Clean up verbose podcast mode debug logs in app.py
This commit is contained in:
ajaysi
2026-05-06 15:29:12 +05:30
parent a7d2ef1c09
commit 3f984e8d0c
31 changed files with 4926 additions and 1011 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Stack, Paper, Box, Chip, Typography } from "@mui/material";
import { Stack, Paper, Box, Chip, Typography, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { CreateProjectPayload, Knobs, PodcastMode } from "./types";
import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
@@ -16,6 +17,7 @@ import { AvatarSelector } from "./CreateStep/AvatarSelector";
import { CreateActions } from "./CreateStep/CreateActions";
import { EnhancedTopicChoicesModal } from "./EnhancedTopicChoicesModal";
import { TrendingTopicsModal } from "./CreateStep/TrendingTopicsModal";
import { CategoryResearchModal } from "./CreateStep/CategoryResearchModal";
const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
"Analyzing your topic idea...",
@@ -23,6 +25,53 @@ const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
"Aligning language for podcast listeners...",
];
// Dynamic progress messages based on context
const getEnhanceProgressMessage = (index: number, hasWebsite: boolean, hasTopicContext: boolean): string => {
const messagesWithAll = [
"Analyzing your topic with website and category research...",
"Incorporating website insights and research findings...",
"Generating podcast angles based on all available context...",
"Creating personalized episode concepts...",
"Finalizing enhanced pitch options...",
];
const messagesWithWebsite = [
"Analyzing your topic with website content...",
"Incorporating website insights and company details...",
"Generating podcast angles based on your website analysis...",
"Creating personalized episode concepts...",
"Finalizing enhanced pitch options...",
];
const messagesWithTopic = [
"Analyzing your topic with category research...",
"Incorporating research insights and trends...",
"Generating podcast angles based on your research...",
"Creating personalized episode concepts...",
"Finalizing enhanced pitch options...",
];
const messagesBasic = [
"Analyzing your topic idea...",
"Enhancing clarity and hook...",
"Aligning language for podcast listeners...",
"Crafting compelling angles...",
"Finalizing recommendations...",
];
let messages;
if (hasWebsite && hasTopicContext) {
messages = messagesWithAll;
} else if (hasWebsite) {
messages = messagesWithWebsite;
} else if (hasTopicContext) {
messages = messagesWithTopic;
} else {
messages = messagesBasic;
}
return messages[index % messages.length];
};
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
open: boolean;
@@ -61,20 +110,96 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const [enhancedRationales, setEnhancedRationales] = useState<string[]>([]);
const [choicesModalOpen, setChoicesModalOpen] = useState(false);
const [editedChoices, setEditedChoices] = useState<string[]>([]);
// Website extraction data for AI enhance
const [websiteData, setWebsiteData] = useState<{
title?: string;
text?: string;
summary?: string;
highlights?: string[];
url: string;
subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>;
} | null>(null);
// Category research context for AI enhance
const [topicContext, setTopicContext] = useState<{
category: string;
topics: Array<{title: string; url: string; snippet: string; score: number}>;
selected_topic: {title: string; url: string; snippet: string};
} | null>(null);
// Enhance topic progress modal state
const [showEnhanceProgressModal, setShowEnhanceProgressModal] = useState(false);
// Trending topics state
const [trendingModalOpen, setTrendingModalOpen] = useState(false);
const [trendingLoading, setTrendingLoading] = useState(false);
// Rotate placeholder every 3 seconds
useEffect(() => {
if (!topicInput) {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
}, 3000);
return () => clearInterval(interval);
// Category research state
const [categoryResearchOpen, setCategoryResearchOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<"news" | "finance" | "research-paper" | "personal-site">("news");
const [categoryLoading, setCategoryLoading] = useState(false);
const [categoryTopics, setCategoryTopics] = useState<Array<{
title: string;
url: string;
snippet: string;
score: number;
favicon?: string;
}>>([]);
const [categoryError, setCategoryError] = useState<string | null>(null);
const [categoryCached, setCategoryCached] = useState(false);
const [lastSearchedTopic, setLastSearchedTopic] = useState<string>("");
const [lastSearchedCategory, setLastSearchedCategory] = useState<"news" | "finance" | "research-paper" | "personal-site">("news");
// Rotate placeholder every 3 seconds
useEffect(() => {
if (!topicInput) {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
}, 3000);
return () => clearInterval(interval);
}
}, [topicInput]);
// Cost estimate state - compatible with TopicUrlInput props
type EstimateType = number | { ttsCost: number; avatarCost: number; videoCost: number; researchCost: number; total: number; } | null;
const [estimatedCost, setEstimatedCost] = useState<EstimateType>(null);
const [costEstimateLoading, setCostEstimateLoading] = useState(false);
// Fetch cost estimate when config changes
useEffect(() => {
const fetchEstimate = async () => {
if (!duration || !speakers || !podcastMode) return;
setCostEstimateLoading(true);
try {
const result = await podcastApi.preEstimateCost({
duration,
speakers,
queryCount: 3, // Default to 3 queries
podcastMode,
});
console.log('[Cost Estimate] Response:', result);
console.log('[Cost Estimate] Total:', result.estimate?.total);
console.log('[Cost Estimate] Full breakdown:', result.estimate);
if (result.estimate?.total !== undefined) {
// Store full estimate object for tooltip
setEstimatedCost(result.estimate);
} else {
setEstimatedCost(null);
}
} catch (error) {
console.error("Cost estimate error:", error);
setEstimatedCost(null);
} finally {
setCostEstimateLoading(false);
}
}, [topicInput]);
};
fetchEstimate();
}, [duration, speakers, podcastMode]);
// Fetch Brand Avatar on mount but don't select it
useEffect(() => {
@@ -94,6 +219,28 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
fetchBrandAvatar();
}, []);
// Load saved website extraction on mount
useEffect(() => {
const loadSavedWebsiteExtraction = async () => {
try {
const result = await podcastApi.getWebsiteExtraction();
if (result.success && result.data) {
setWebsiteData({
title: result.data.title,
text: result.data.text,
summary: result.data.summary,
highlights: result.data.highlights,
url: result.data.url,
subpages: result.data.subpages,
});
}
} catch (error) {
console.warn("Failed to load saved website extraction:", error);
}
};
loadSavedWebsiteExtraction();
}, []);
useEffect(() => {
if (!avatarPreview) {
setAvatarPreviewBlobUrl(null);
@@ -204,7 +351,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
};
const isUrl = useMemo(() => detectUrl(topicInput), [topicInput]);
const enhanceTopicMessage = enhancingTopic ? ENHANCE_TOPIC_PROGRESS_MESSAGES[enhanceTopicProgressIndex] : undefined;
const enhanceTopicMessage = enhancingTopic ? getEnhanceProgressMessage(enhanceTopicProgressIndex, !!websiteData, !!topicContext) : undefined;
useEffect(() => {
if (!enhancingTopic) {
@@ -213,22 +360,39 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
const interval = setInterval(() => {
setEnhanceTopicProgressIndex((prev) => (prev + 1) % ENHANCE_TOPIC_PROGRESS_MESSAGES.length);
setEnhanceTopicProgressIndex((prev) => {
const maxMessages = (websiteData || topicContext) ? 5 : 3;
return (prev + 1) % maxMessages;
});
}, 1200);
return () => clearInterval(interval);
}, [enhancingTopic]);
}, [enhancingTopic, websiteData, topicContext]);
// Handle AI Details button click
const handleAIDetailsClick = async () => {
if (!topicInput.trim() || enhancingTopic) return;
// Show progress modal
setShowEnhanceProgressModal(true);
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
// Build website data (excluding images/favicon)
const websiteDataForApi = websiteData ? {
title: websiteData.title,
text: websiteData.text,
summary: websiteData.summary,
highlights: websiteData.highlights,
url: websiteData.url,
subpages: websiteData.subpages,
} : undefined;
const result = await podcastApi.enhanceIdea({
idea: topicInput,
website_data: websiteDataForApi,
topic_context: topicContext || undefined,
});
if (result.enhanced_ideas && result.enhanced_ideas.length === 3) {
@@ -241,9 +405,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
console.error("Failed to enhance idea with AI:", error);
} finally {
setEnhancingTopic(false);
setShowEnhanceProgressModal(false);
}
};
// Handle Category Research (News/Finance/Research Papers/Personal Website) click
const handleCategoryResearchClick = async (category: "news" | "finance" | "research-paper" | "personal-site", websiteUrl?: string, forceRefresh: boolean = false, overrideKeyword?: string) => {
const currentTopic = (overrideKeyword || topicInput.trim());
// Check if we have cached results for the same topic + category combination (only if not force refresh)
if (!forceRefresh && !overrideKeyword && currentTopic === lastSearchedTopic && category === lastSearchedCategory && categoryTopics.length > 0) {
setSelectedCategory(category);
setCategoryResearchOpen(true);
setCategoryCached(true);
setCategoryLoading(false);
return;
}
setSelectedCategory(category);
setCategoryResearchOpen(true);
setCategoryLoading(true);
setCategoryError(null);
setCategoryCached(false);
setCategoryTopics([]);
// For personal-site, check if topic input looks like a URL
let websiteUrlToUse: string | undefined;
if (category === "personal-site" && topicInput.trim()) {
const topicText = topicInput.trim();
// Check if it looks like a URL
if (topicText.startsWith('http://') || topicText.startsWith('https://') || topicText.includes('://') || (topicText.includes('.') && !topicText.includes(' '))) {
websiteUrlToUse = topicText;
}
}
try {
const result = await podcastApi.researchByCategory({
category,
keyword: currentTopic || undefined,
maxResults: 8,
websiteUrl: websiteUrlToUse,
});
if (result.success) {
setCategoryTopics(result.topics || []);
setLastSearchedTopic(currentTopic);
setLastSearchedCategory(category);
} else {
setCategoryError(result.error || `Failed to fetch ${category} topics`);
}
} catch (error: any) {
setCategoryError(error?.message || `Failed to fetch ${category} topics`);
} finally {
setCategoryLoading(false);
}
};
// Handle Redo Search for category research
const handleCategoryRedoSearch = (keyword: string, websiteUrl?: string) => {
handleCategoryResearchClick(selectedCategory, websiteUrl, true, keyword);
};
// Handle enhanced topic choice selection
const handleChoiceSelection = (selectedIndex: number, editedChoice: string) => {
const selectedTopic = editedChoice;
@@ -290,20 +512,39 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
// 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.
// Extract the URL from the input
const urlMatch = topicInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch) {
finalUrl = urlMatch[0];
} else {
// Fallback
finalUrl = topicInput;
const detectedUrl = urlMatch ? urlMatch[0] : topicInput;
// Extract content from the URL using Exa
try {
setEnhancingTopic(true);
setEnhanceTopicProgressIndex(0);
const { podcastApi } = await import("../../services/podcastApi");
const extractResult = await podcastApi.extractUrl({ url: detectedUrl });
if (extractResult.success && extractResult.summary) {
// Use extracted content as the podcast topic
finalIdea = extractResult.summary;
if (extractResult.title) {
finalIdea = `${extractResult.title}: ${finalIdea}`;
}
} else if (extractResult.success && extractResult.text) {
// Fallback to text if no summary
finalIdea = extractResult.text.substring(0, 500);
} else {
// Fallback: use the URL itself if extraction fails
finalIdea = detectedUrl;
console.warn("[CreateModal] URL extraction failed:", extractResult.error);
}
} catch (error) {
console.error("[CreateModal] URL extraction error:", error);
finalIdea = detectedUrl; // Fallback to URL
} finally {
setEnhancingTopic(false);
}
} else {
finalIdea = topicInput;
@@ -370,7 +611,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
try {
await onCreate({
ideaOrUrl: finalUrl || finalIdea,
ideaOrUrl: finalIdea,
speakers,
duration,
knobs: finalKnobs,
@@ -588,13 +829,18 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
showAIDetailsButton={showAIDetailsButton}
onAIDetailsClick={handleAIDetailsClick}
onTrendingTopicsClick={() => setTrendingModalOpen(true)}
onCategoryResearchClick={handleCategoryResearchClick}
placeholderIndex={placeholderIndex}
loading={enhancingTopic}
loadingMessage={enhanceTopicMessage}
extractedData={websiteData}
setExtractedData={setWebsiteData}
trendingLoading={trendingLoading}
estimatedCost={null}
categoryResearchLoading={categoryLoading}
estimatedCost={estimatedCost}
duration={duration}
speakers={speakers}
podcastMode={podcastMode}
knobs={knobs}
/>
</Box>
@@ -666,6 +912,127 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
onSelectTopic={(topic) => setTopicInput(topic)}
initialKeywords={topicInput}
/>
{/* Category Research Modal */}
<CategoryResearchModal
open={categoryResearchOpen}
onClose={() => setCategoryResearchOpen(false)}
category={selectedCategory}
keyword={topicInput}
websiteUrl={selectedCategory === "personal-site" ? topicInput : undefined}
loading={categoryLoading}
topics={categoryTopics}
error={categoryError}
onSelectTopic={(topic) => {
// Save topic context
const selectedTopicData = categoryTopics.find(t => t.title === topic);
if (selectedTopicData) {
setTopicContext({
category: selectedCategory,
topics: categoryTopics.map(t => ({title: t.title, url: t.url, snippet: t.snippet, score: t.score})),
selected_topic: {
title: selectedTopicData.title,
url: selectedTopicData.url,
snippet: selectedTopicData.snippet,
},
});
}
setTopicInput(topic);
setCategoryResearchOpen(false);
}}
onRedoSearch={handleCategoryRedoSearch}
onConfirmSelection={(selectedTopics) => {
if (selectedTopics.length > 0) {
// Save topic context
const firstSelected = categoryTopics.find(t => t.title === selectedTopics[0]);
if (firstSelected) {
setTopicContext({
category: selectedCategory,
topics: categoryTopics.map(t => ({title: t.title, url: t.url, snippet: t.snippet, score: t.score})),
selected_topic: {
title: firstSelected.title,
url: firstSelected.url,
snippet: firstSelected.snippet,
},
});
}
setTopicInput(selectedTopics[0]);
}
setCategoryResearchOpen(false);
}}
isCached={categoryCached}
/>
{/* Enhance Topic Progress Modal */}
<Dialog
open={showEnhanceProgressModal}
disableEscapeKeyDown={false}
onClose={() => setShowEnhanceProgressModal(false)}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: "linear-gradient(135deg, #1e1b4b 0%, #312e81 100%)",
backgroundColor: "#1e1b4b",
color: "#fff",
borderRadius: 3,
boxShadow: "0 8px 40px rgba(49, 46, 129, 0.4)",
},
}}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, fontWeight: 600 }}>
<CircularProgress size={20} sx={{ color: "#a78bfa" }} />
Enhancing Your Topic
</DialogTitle>
<DialogContent sx={{ textAlign: "center", py: 4 }}>
<Box sx={{ mb: 3 }}>
<CircularProgress
size={60}
thickness={4}
sx={{
color: "#a78bfa",
mb: 2,
}}
/>
</Box>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
{enhanceTopicMessage || "Processing your topic..."}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mb: 2 }}>
This may take a few seconds
</Typography>
{/* Context info */}
<Box sx={{ mt: 3, p: 2, bgcolor: "rgba(255,255,255,0.1)", borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", display: "block", mb: 1 }}>
Using context from:
</Typography>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
{websiteData && (
<Chip
size="small"
label={websiteData.title ? `${websiteData.title.slice(0, 15)}...` : "Website"}
sx={{ bgcolor: "rgba(167, 139, 250, 0.3)", color: "#fff" }}
/>
)}
{topicContext && (
<Chip
size="small"
label={`${topicContext.category.charAt(0).toUpperCase() + topicContext.category.slice(1)} Research`}
sx={{ bgcolor: "rgba(16, 185, 129, 0.3)", color: "#fff" }}
/>
)}
{(!websiteData && !topicContext) && (
<Chip
size="small"
label="Topic only"
sx={{ bgcolor: "rgba(100, 116, 139, 0.3)", color: "#fff" }}
/>
)}
</Stack>
</Box>
</DialogContent>
</Dialog>
</Stack>
</Paper>
);

View File

@@ -102,80 +102,76 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
sx={{
flex: 1,
minWidth: 0,
p: { xs: 1.5, sm: 2.5 },
borderRadius: 2,
borderRadius: 3,
background: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.08)",
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
border: "1px solid",
borderColor: "#e2e8f0",
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "3px",
background: "linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%)",
},
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 1.5 }} alignItems={{ xs: "flex-start", sm: "center" }} sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
}}
>
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>3</Typography>
</Box>
<Box
sx={{
width: 36,
height: 36,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
</Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Podcast Presenter Avatar
</Typography>
</Stack>
<Tooltip
title={
{/* Header with Tabs */}
<Box sx={{ px: 2.5, pt: 2.5, pb: 1.5, background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", borderBottom: "1px solid #e0e7ff" }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1.5, sm: 1.5 }} alignItems={{ xs: "flex-start", sm: "center" }} justifyContent="space-between">
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
width: 28,
height: 28,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>3</Typography>
</Box>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
}}
>
<PersonIcon fontSize="medium" sx={{ color: "#6366f1" }} />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Avatar Options:
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1rem", letterSpacing: "-0.01em" }}>
Podcast Presenter Avatar
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/><br/>
<strong>Asset Library:</strong> Choose from your previously uploaded images.<br/><br/>
<strong>Take a Selfie:</strong> Use your camera to capture a photo instantly for your podcast presenter.<br/><br/>
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", display: "block", mt: -0.25 }}>
Select or upload an image for your presenter
</Typography>
</Box>
}
arrow
placement="top"
>
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help", ml: { xs: 0, sm: 0 } }} />
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
{/* Left Side: Tabs & Content */}
<Box sx={{ flex: 1, width: "100%" }}>
</Stack>
{/* Tabs in header - Mobile Responsive */}
<Tabs
value={avatarTab}
onChange={setAvatarTab}
variant="scrollable"
scrollButtons={isMobile ? "auto" : false}
allowScrollButtonsMobile={isMobile}
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
mb: { xs: 2, sm: 3 },
minHeight: { xs: 36, sm: 48 },
minHeight: { xs: 32, sm: 38 },
"& .MuiTabs-scrollButtons": {
color: "#64748b",
"&.Mui-disabled": { opacity: 0.3 },
@@ -184,30 +180,30 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
display: "none",
},
"& .MuiTabs-flexContainer": {
gap: { xs: 0.5, sm: 1.5 },
gap: 0.5,
},
"& .MuiTab-root": {
textTransform: "none",
minHeight: { xs: 32, sm: 44 },
minHeight: { xs: 28, sm: 36 },
fontWeight: 600,
fontSize: { xs: "0.7rem", sm: "0.875rem" },
borderRadius: { xs: "6px", sm: "12px" },
px: { xs: 1, sm: 2.5 },
minWidth: { xs: "auto", sm: 0 },
fontSize: { xs: "0.65rem", sm: "0.8rem" },
borderRadius: 1,
px: { xs: 1, sm: 1.5 },
py: 0.5,
minWidth: "auto",
color: "#64748b",
border: "1.5px solid #e2e8f0",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
border: "1px solid #e2e8f0",
backgroundColor: "#ffffff",
transition: "all 0.2s ease",
"&:hover": {
borderColor: "#cbd5e1",
backgroundColor: "#f8fafc",
transform: { xs: "none", sm: "translateY(-1px)" },
borderColor: "#c7d2fe",
backgroundColor: "#eef2ff",
},
"&.Mui-selected": {
color: "#ffffff",
borderColor: "transparent",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.3)",
},
},
}}
@@ -216,6 +212,33 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
<Tab key={index} label={label} />
))}
</Tabs>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
Avatar Options:
</Typography>
<Typography variant="caption" component="div" sx={{ lineHeight: 1.6, color: "#e5e7eb" }}>
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/>
<strong>Asset Library:</strong> Choose from your previously uploaded images.<br/>
<strong>Take a Selfie:</strong> Use your camera to capture a photo instantly.<br/>
<strong>Upload your photo:</strong> We'll enhance it into a professional presenter.
</Typography>
</Box>
}
arrow
placement="top"
>
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
</Tooltip>
</Stack>
</Box>
{/* Content Area */}
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start" sx={{ p: 2.5 }}>
{/* Left Side: Content based on selected tab */}
<Box sx={{ flex: 1, width: "100%" }}>
{avatarTab === 0 && (
<Stack spacing={2}>

View File

@@ -0,0 +1,602 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Stack,
CircularProgress,
Alert,
Chip,
IconButton,
TextField,
Tooltip,
Checkbox,
} from "@mui/material";
import {
Newspaper as NewspaperIcon,
ShowChart as ShowChartIcon,
School as SchoolIcon,
Public as PublicIcon,
Close as CloseIcon,
OpenInNew as OpenInNewIcon,
Refresh as RefreshIcon,
CheckCircle as CheckCircleIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
Language as LanguageIcon,
} from "@mui/icons-material";
interface CategoryTopic {
title: string;
url: string;
snippet: string;
score: number;
favicon?: string;
}
type CategoryType = "news" | "finance" | "research-paper" | "personal-site";
interface CategoryResearchModalProps {
open: boolean;
onClose: () => void;
category: CategoryType;
keyword?: string;
websiteUrl?: string;
loading?: boolean;
topics?: CategoryTopic[];
error?: string | null;
onSelectTopic: (topic: string) => void;
onRedoSearch?: (keyword: string, websiteUrl?: string) => void;
onConfirmSelection?: (selectedTopics: string[]) => void;
isCached?: boolean;
}
const CATEGORY_CONFIG: Record<CategoryType, { label: string; icon: React.ReactNode; color: string; bgLight: string }> = {
"news": { label: "News", icon: <NewspaperIcon />, color: "#4F46E5", bgLight: "#EEF2FF" },
"finance": { label: "Finance", icon: <ShowChartIcon />, color: "#059669", bgLight: "#ECFDF5" },
"research-paper": { label: "Research Papers", icon: <SchoolIcon />, color: "#7C3AED", bgLight: "#F3E8FF" },
"personal-site": { label: "Personal Website", icon: <PublicIcon />, color: "#D97706", bgLight: "#FEF3C7" },
};
const BEST_PRACTICES: Record<CategoryType, string[]> = {
"news": [
"Use specific, focused keywords for better results",
"Include relevant industry or niche terms",
"Add location or timeframe for localized news",
"Avoid very general terms like 'news' or 'updates'",
],
"finance": [
"Use specific, focused keywords for better results",
"Include asset class (stocks, crypto, forex, bonds)",
"Add timeframe (q1 2024, last month, etc.)",
"Include market or sector names for targeted results",
],
"research-paper": [
"Use academic keywords and terminology",
"Include specific topics or research areas",
"Add field of study (AI, medicine, climate, etc.)",
"Works best with technical or scientific topics",
],
"personal-site": [
"Enter the website URL in the input field below",
"The search will find content within that domain",
"Use specific page or topic keywords for best results",
"Leave keyword empty to get all pages from the site",
],
};
export const CategoryResearchModal: React.FC<CategoryResearchModalProps> = ({
open,
onClose,
category,
keyword,
websiteUrl = "",
loading = false,
topics = [],
error = null,
onSelectTopic,
onRedoSearch,
onConfirmSelection,
isCached = false,
}) => {
const config = CATEGORY_CONFIG[category];
const categoryLabel = config.label;
const categoryIcon = config.icon;
const categoryColor = config.color;
const categoryBgLight = config.bgLight;
const [redoKeyword, setRedoKeyword] = useState(keyword || "");
const [localWebsiteUrl, setLocalWebsiteUrl] = useState(websiteUrl);
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
useEffect(() => {
if (open) {
setRedoKeyword(keyword || "");
setLocalWebsiteUrl(websiteUrl || "");
setSelectedTopics(new Set());
}
}, [open, keyword, websiteUrl]);
const handleSelectTopic = (topic: CategoryTopic) => {
onSelectTopic(topic.title);
};
const handleClose = () => {
onClose();
};
const handleRedoClick = () => {
if (onRedoSearch && redoKeyword.trim()) {
onRedoSearch(redoKeyword.trim(), category === "personal-site" ? localWebsiteUrl : undefined);
}
};
const handleToggleSelectTopic = (title: string) => {
const newSelected = new Set(selectedTopics);
if (newSelected.has(title)) {
newSelected.delete(title);
} else {
newSelected.add(title);
}
setSelectedTopics(newSelected);
};
const handleSelectAll = () => {
const allTitles = new Set(topics.map(t => t.title));
setSelectedTopics(allTitles);
};
const handleDeselectAll = () => {
setSelectedTopics(new Set());
};
const handleConfirm = () => {
if (onConfirmSelection && selectedTopics.size > 0) {
onConfirmSelection(Array.from(selectedTopics));
onClose();
}
};
const getDomain = (url: string) => {
try {
return new URL(url).hostname.replace("www.", "");
} catch {
return url;
}
};
const isPersonalSite = category === "personal-site";
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
background: "#ffffff",
backgroundImage: "none",
},
}}
>
<DialogTitle
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
pb: 1.5,
pt: 2.5,
px: 3,
borderBottom: "1px solid #e5e7eb",
background: "#fafafa",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2.5,
background: `linear-gradient(135deg, ${categoryColor}08 0%, ${categoryColor}04 100%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: categoryColor,
}}
>
{categoryIcon}
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: "1.25rem", lineHeight: 1.3, color: "#111827" }}>
{categoryLabel}
</Typography>
{keyword && (
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.875rem", mt: 0.25 }}>
Searching: <Typography component="span" sx={{ fontWeight: 600, color: "#374151" }}>{keyword}</Typography>
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: "#fff" }}>
Best Practices for Search
</Typography>
{BEST_PRACTICES[category].map((tip, idx) => (
<Typography key={idx} variant="caption" sx={{ display: "block", mb: 0.5, color: "#e5e7eb" }}>
{tip}
</Typography>
))}
</Box>
}
arrow
placement="bottom-end"
>
<Chip
icon={<LightbulbIcon sx={{ fontSize: "14px !important" }} />}
label="For best results"
size="small"
sx={{
background: categoryBgLight,
color: categoryColor,
border: `1px solid ${categoryColor}25`,
fontWeight: 600,
fontSize: "0.75rem",
cursor: "help",
"& .MuiChip-icon": { color: categoryColor },
"&:hover": {
background: `${categoryColor}15`,
},
}}
/>
</Tooltip>
<IconButton onClick={handleClose} size="small" sx={{ color: "#9ca3af" }}>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ pt: 3, pb: 2, px: 3, minHeight: 360 }}>
{loading && (
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", py: 8 }}>
<CircularProgress size={48} sx={{ color: categoryColor, mb: 2.5 }} />
<Typography variant="h6" sx={{ color: "#374151", fontWeight: 600, mb: 0.5 }}>
Searching {categoryLabel.toLowerCase()}...
</Typography>
<Typography variant="body2" sx={{ color: "#6b7280" }}>
{isPersonalSite
? `Searching within ${localWebsiteUrl || "your website"}`
: `Finding relevant ${categoryLabel.toLowerCase()} for your podcast`}
</Typography>
</Box>
)}
{error && (
<Alert
severity="error"
sx={{
borderRadius: 2,
background: "#fef2f2",
border: "1px solid #fecaca",
color: "#dc2626",
"& .MuiAlert-icon": { color: "#dc2626" }
}}
>
{error}
</Alert>
)}
{!loading && !error && topics.length === 0 && (
<Box sx={{ py: 6, textAlign: "center" }}>
<Box sx={{
width: 64,
height: 64,
borderRadius: "50%",
background: "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
mx: "auto",
mb: 2
}}>
{React.cloneElement(categoryIcon as React.ReactElement, { sx: { fontSize: 32, color: "#d1d5db" } })}
</Box>
<Typography variant="h6" sx={{ color: "#374151", fontWeight: 600, mb: 0.5 }}>
No results found
</Typography>
<Typography variant="body2" sx={{ color: "#6b7280" }}>
{isPersonalSite
? "Enter a website URL and try different keywords"
: "Try different search terms or redo search"}
</Typography>
</Box>
)}
{!loading && !error && topics.length > 0 && (
<>
{/* Redo Search Bar */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
px: 2,
py: 1.5,
mb: 2,
background: "#f9fafb",
borderRadius: 2,
border: "1px solid #e5e7eb",
flexWrap: "wrap",
}}
>
<RefreshIcon sx={{ fontSize: 18, color: categoryColor }} />
<Typography variant="body2" sx={{ color: "#374151", fontWeight: 500, fontSize: "0.875rem", flexShrink: 0 }}>
Search again
</Typography>
{/* Website URL input for Personal Site */}
{isPersonalSite && (
<TextField
size="small"
placeholder="Enter website URL (e.g., example.com)"
value={localWebsiteUrl}
onChange={(e) => setLocalWebsiteUrl(e.target.value)}
sx={{
width: 260,
"& .MuiOutlinedInput-root": {
background: "#fff",
fontSize: "0.8rem",
height: 34,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "#d1d5db",
},
}}
/>
)}
<TextField
size="small"
placeholder="Enter search term..."
value={redoKeyword}
onChange={(e) => setRedoKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRedoClick()}
sx={{
flex: 1,
minWidth: 150,
maxWidth: 280,
"& .MuiOutlinedInput-root": {
background: "#fff",
fontSize: "0.8rem",
height: 34,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "#d1d5db",
},
}}
/>
<Button
size="small"
variant="contained"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={handleRedoClick}
disabled={!redoKeyword.trim() || (isPersonalSite && !localWebsiteUrl.trim())}
sx={{
textTransform: "none",
fontSize: "0.75rem",
fontWeight: 600,
color: "#fff",
background: categoryColor,
borderRadius: 1.5,
px: 1.5,
height: 34,
"&:hover": {
background: categoryColor,
opacity: 0.9,
},
"&:disabled": {
background: "#e5e7eb",
color: "#9ca3af",
},
}}
>
Search
</Button>
</Box>
{/* Select All / Deselect All */}
{topics.length > 0 && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5, justifyContent: "flex-end" }}>
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.8rem", mr: 1 }}>
{selectedTopics.size} of {topics.length} selected
</Typography>
<Button
size="small"
onClick={handleSelectAll}
sx={{ textTransform: "none", fontSize: "0.75rem", fontWeight: 600, color: categoryColor, minWidth: "auto", px: 1 }}
>
Select All
</Button>
<Typography variant="body2" sx={{ color: "#d1d5db" }}>|</Typography>
<Button
size="small"
onClick={handleDeselectAll}
disabled={selectedTopics.size === 0}
sx={{ textTransform: "none", fontSize: "0.75rem", fontWeight: 600, color: "#6b7280", minWidth: "auto", px: 1 }}
>
Deselect All
</Button>
</Box>
)}
<Stack spacing={1.5}>
{topics.map((topic, idx) => (
<Box
key={idx}
sx={{
p: 2,
borderRadius: 2,
border: selectedTopics.has(topic.title)
? `2px solid ${categoryColor}`
: "1px solid #e5e7eb",
background: selectedTopics.has(topic.title)
? categoryBgLight
: "#ffffff",
cursor: "pointer",
transition: "all 0.2s ease",
"&:hover": {
background: "#f9fafb",
borderColor: categoryColor,
},
}}
>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5 }}>
<Checkbox
checked={selectedTopics.has(topic.title)}
onChange={() => handleToggleSelectTopic(topic.title)}
sx={{
p: 0,
mt: 0.25,
color: "#d1d5db",
"&.Mui-checked": { color: categoryColor },
}}
/>
<Box sx={{ flex: 1 }} onClick={() => handleSelectTopic(topic)}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
fontSize: "0.95rem",
lineHeight: 1.5,
mb: 0.5,
color: "#111827",
}}
>
{topic.title}
</Typography>
<Typography
variant="body2"
sx={{
color: "#4b5563",
fontSize: "0.8rem",
lineHeight: 1.5,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{topic.snippet}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mt: 1 }}>
{topic.favicon && (
<Box
component="img"
src={topic.favicon}
alt=""
sx={{ width: 14, height: 14, borderRadius: 0.5 }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<Typography variant="body2" sx={{ color: "#6b7280", fontSize: "0.75rem" }}>
{getDomain(topic.url)}
</Typography>
<Chip
label={`${Math.round(topic.score * 100)}%`}
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
fontWeight: 600,
background: `${categoryColor}12`,
color: categoryColor,
borderRadius: 1,
}}
/>
</Box>
</Box>
<OpenInNewIcon sx={{ fontSize: 14, color: "#9ca3af", flexShrink: 0, mt: 0.5 }} />
</Box>
</Box>
))}
</Stack>
</>
)}
</DialogContent>
<DialogActions
sx={{
px: 3,
py: 2,
borderTop: "1px solid #e5e7eb",
background: "#f9fafb",
justifyContent: "space-between",
}}
>
<Typography variant="body2" sx={{ color: "#9ca3af", fontSize: "0.8rem" }}>
{topics.length} results {category === "news" || category === "finance" ? "Powered by Tavily" : "Powered by Exa"}
</Typography>
<Box sx={{ display: "flex", gap: 1.5 }}>
<Button
onClick={handleClose}
sx={{
textTransform: "none",
fontWeight: 600,
fontSize: "0.875rem",
color: "#6b7280",
background: "#ffffff",
border: "1px solid #d1d5db",
borderRadius: 2,
px: 2.5,
py: 0.75,
"&:hover": {
background: "#f3f4f6",
borderColor: "#9ca3af",
},
}}
>
Discard
</Button>
<Button
onClick={handleConfirm}
disabled={selectedTopics.size === 0}
sx={{
textTransform: "none",
fontWeight: 600,
fontSize: "0.875rem",
color: "#fff",
background: selectedTopics.size > 0
? `linear-gradient(135deg, ${categoryColor} 0%, ${categoryColor}dd 100%)`
: "#e5e7eb",
borderRadius: 2,
px: 2.5,
py: 0.75,
"&:hover": {
background: categoryColor,
opacity: 0.9,
},
"&:disabled": {
background: "#e5e7eb",
color: "#9ca3af",
},
}}
>
Use {selectedTopics.size > 0 ? `${selectedTopics.size} ` : ""}Selected for Podcast
</Button>
</Box>
</DialogActions>
</Dialog>
);
};

View File

@@ -57,14 +57,14 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
sx={{
flex: { xs: "1 1 auto", lg: "0 0 320px" },
width: { xs: "100%", lg: "320px" },
p: 3,
borderRadius: 3,
background: "#ffffff",
border: "1px solid rgba(102, 126, 234, 0.15)",
border: "1px solid",
borderColor: "#e2e8f0",
height: "100%",
display: "flex",
flexDirection: "column",
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.08)",
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
position: "relative",
overflow: "hidden",
"&::before": {
@@ -78,49 +78,47 @@ export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
},
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2.5 }}>
{/* Header with gradient background */}
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2, px: 3, pt: 3, pb: 2, background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", borderBottom: "1px solid #e0e7ff" }}>
<Box
sx={{
width: 24,
height: 24,
width: 28,
height: 28,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>2</Typography>
</Box>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
width: 40,
height: 40,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
}}
>
<SettingsIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
<SettingsIcon sx={{ color: "#6366f1", fontSize: "1.1rem" }} />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1rem", letterSpacing: "-0.01em" }}>
Basic Configuration
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", display: "block", mt: -0.25 }}>
Set duration, speakers, and podcast mode
</Typography>
</Box>
<Typography
variant="subtitle1"
sx={{
color: "#0f172a",
fontWeight: 700,
fontSize: "1rem",
letterSpacing: "-0.01em",
}}
>
Basic Configuration
</Typography>
</Stack>
<Stack spacing={3}>
<Stack spacing={3} sx={{ p: 3, pt: 2 }}>
{/* Podcast Mode */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>

View File

@@ -1,7 +1,9 @@
import React from "react";
import { Box, Typography, TextField, Tooltip, Button, CircularProgress, alpha, Stack, Chip } from "@mui/material";
import { AutoAwesome as AutoAwesomeIcon, AttachMoney as AttachMoneyIcon, TrendingUp as TrendingUpIcon } from "@mui/icons-material";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Box, Typography, TextField, Tooltip, Button, CircularProgress, alpha, Stack, Chip, IconButton, Collapse } from "@mui/material";
import { AutoAwesome as AutoAwesomeIcon, AttachMoney as AttachMoneyIcon, TrendingUp as TrendingUpIcon, Mic as MicIcon, Stop as StopIcon, Language as LanguageIcon, Newspaper as NewspaperIcon, ShowChart as ShowChartIcon, School as SchoolIcon, Public as PublicIcon, Lightbulb as LightbulbIcon } from "@mui/icons-material";
import { Knobs } from "../types";
import { podcastApi } from "../../../services/podcastApi";
import { WebsitePreviewModal } from "./WebsitePreviewModal";
export const TOPIC_PLACEHOLDERS = [
"Industry insights: Latest trends in AI for Content Marketing",
@@ -19,11 +21,14 @@ interface TopicUrlInputProps {
showAIDetailsButton: boolean;
onAIDetailsClick?: () => void;
onTrendingTopicsClick?: () => void;
onCategoryResearchClick?: (category: "news" | "finance" | "research-paper" | "personal-site", websiteUrl?: string) => void;
placeholderIndex: number;
loading?: boolean;
loadingMessage?: string;
trendingLoading?: boolean;
estimatedCost?: {
categoryResearchLoading?: boolean;
// Estimated cost - can be a number (from pre-estimate) or object (from analyze response)
estimatedCost?: number | {
ttsCost: number;
avatarCost: number;
videoCost: number;
@@ -33,6 +38,40 @@ interface TopicUrlInputProps {
duration?: number;
speakers?: number;
knobs?: Knobs;
podcastMode?: string;
// Website extraction data - passed from parent for use with AI enhance
extractedData?: {
title?: string;
text?: string;
summary?: string;
highlights?: string[];
url: string;
image?: string;
favicon?: string;
subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>;
} | null;
setExtractedData?: (data: any) => void;
}
interface SpeechRecognitionType {
lang: string;
continuous: boolean;
interimResults: boolean;
maxAlternatives: number;
onresult: ((event: { results: { isFinal: boolean; [index: number]: { transcript: string } }[], resultIndex: number }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognitionType;
webkitSpeechRecognition: new () => SpeechRecognitionType;
}
}
export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
@@ -42,26 +81,161 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
showAIDetailsButton,
onAIDetailsClick,
onTrendingTopicsClick,
onCategoryResearchClick,
placeholderIndex,
loading = false,
loadingMessage,
trendingLoading = false,
categoryResearchLoading = false,
estimatedCost,
duration = 1,
speakers = 1,
knobs,
podcastMode = "audio_video",
extractedData: extractedDataProp,
setExtractedData: setExtractedDataProp,
}) => {
// Helper to get total cost from various estimate formats (number | object | null)
const getTotalCost = (cost: number | { total: number } | null | undefined): number | null => {
if (cost === null || cost === undefined) return null;
if (typeof cost === "number") return cost;
if (typeof cost === "object" && "total" in cost) return cost.total;
return null;
};
const totalCost = getTotalCost(estimatedCost);
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognitionType | null>(null);
// Use props if provided, otherwise use local state (for backward compatibility)
const [localExtractedData, setLocalExtractedData] = useState<any>(null);
const _extractedData = extractedDataProp !== undefined ? extractedDataProp : localExtractedData;
const _setExtractedData = setExtractedDataProp || setLocalExtractedData;
// Website extraction state
const [showWebsiteInput, setShowWebsiteInput] = useState(false);
const [websiteUrl, setWebsiteUrl] = useState("");
const [isExtracting, setIsExtracting] = useState(false);
const [extractedData, setExtractedData] = useState<{title?: string; text?: string; summary?: string; highlights?: string[]; url: string; image?: string; favicon?: string; subpages?: Array<{id?: string; title?: string; url?: string; summary?: string; text?: string}>} | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [websiteError, setWebsiteError] = useState<string | null>(null);
const isSupported = typeof window !== 'undefined' && (window.SpeechRecognition !== undefined || window.webkitSpeechRecognition !== undefined);
const getBrowserLanguage = (): string => {
const lang = (navigator.language || '').toLowerCase();
if (lang.startsWith('en')) return 'en-US';
if (lang.startsWith('hi')) return 'hi-IN';
if (lang.startsWith('es')) return 'es-ES';
if (lang.startsWith('fr')) return 'fr-FR';
if (lang.startsWith('de')) return 'de-DE';
if (lang.startsWith('zh')) return 'zh-CN';
if (lang.startsWith('ja')) return 'ja-JP';
if (lang.startsWith('ko')) return 'ko-KR';
return 'en-US';
};
const startListening = useCallback(() => {
if (!isSupported) {
setError('Speech recognition is not supported in this browser. Try Chrome or Edge.');
return;
}
setError(null);
const SpeechRecognitionAPI = window.SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!recognitionRef.current) {
const recognition = new SpeechRecognitionAPI() as SpeechRecognitionType;
recognition.lang = getBrowserLanguage();
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
let transcript = '';
let isFinal = false;
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
if (event.results[i].isFinal) {
isFinal = true;
}
}
if (isFinal) {
const newValue = value ? `${value} ${transcript.trim()}`.trim() : transcript.trim();
onChange(newValue);
}
};
recognition.onerror = (event) => {
console.error('[Speech] Error:', event.error);
if (event.error === 'not-allowed') {
setError('Microphone access denied. Please allow microphone access in your browser settings.');
} else if (event.error === 'network') {
setError('Network error. Please check your internet connection.');
} else if (event.error !== 'aborted') {
setError(`Speech recognition error: ${event.error}`);
}
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognitionRef.current = recognition;
}
recognitionRef.current.onstart = () => {
setIsListening(true);
};
try {
recognitionRef.current.start();
} catch (e) {
console.error('[Speech] Start error:', e);
setError('Failed to start speech recognition. Please try again.');
}
}, [isSupported, onChange, value]);
const stopListening = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
}
setIsListening(false);
}, []);
const handleMicClick = useCallback(() => {
if (isListening) {
stopListening();
} else {
startListening();
}
}, [isListening, stopListening, startListening]);
useEffect(() => {
return () => {
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
return (
<Box
sx={{
p: 3,
p: 0,
borderRadius: 3,
background: "#ffffff",
border: "1px solid rgba(102, 126, 234, 0.15)",
border: "1px solid",
borderColor: "#e2e8f0",
height: "100%",
display: "flex",
flexDirection: "column",
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.08)",
boxShadow: "0 8px 30px rgba(15, 23, 42, 0.12)",
position: "relative",
overflow: "hidden",
"&::before": {
@@ -75,183 +249,419 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
},
}}
>
<Box flex={1} display="flex" flexDirection="column">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Stack direction="row" alignItems="center" spacing={1}>
{/* Header with gradient background */}
<Box flex={1} display="flex" flexDirection="column" sx={{ background: "linear-gradient(180deg, #eff6ff 0%, #f0f9ff 60%, #ffffff 100%)", px: 3, pt: 3, pb: 2, borderBottom: "1px solid #e0e7ff" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
sx={{
width: 24,
height: 24,
width: 28,
height: 28,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
<Typography sx={{ color: "#fff", fontSize: "0.75rem", fontWeight: 700 }}>1</Typography>
</Box>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
width: 40,
height: 40,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5)",
}}
>
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
<LightbulbIcon sx={{ color: "#6366f1", fontSize: "1.1rem" }} />
</Box>
<Typography
variant="subtitle1"
sx={{
color: "#0f172a",
fontWeight: 700,
fontSize: "1rem",
letterSpacing: "-0.01em",
}}
>
Enter Podcast Topic or Blog URL
</Typography>
</Stack>
<Tooltip
title={
estimatedCost ? (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost Breakdown:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
Audio Generation: ${estimatedCost.ttsCost}<br />
Avatar Creation: ${estimatedCost.avatarCost}<br />
Video Rendering: ${estimatedCost.videoCost}<br />
Research: ${estimatedCost.researchCost}<br />
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
Total: ${estimatedCost.total}
<Stack direction="row" spacing={1} alignItems="center">
{!showWebsiteInput && (
<Chip
icon={<LanguageIcon sx={{ fontSize: "0.875rem !important" }} />}
label="Your Website"
onClick={() => setShowWebsiteInput(true)}
disabled={loading}
size="small"
sx={{
background: "rgba(102, 126, 234, 0.08)",
color: "#667eea",
border: "1px solid rgba(102, 126, 234, 0.25)",
fontWeight: 600,
fontSize: "0.75rem",
height: 26,
"&:hover": {
background: "rgba(102, 126, 234, 0.15)",
transform: "scale(1.02)",
},
}}
/>
)}
<Tooltip
title={
totalCost && estimatedCost ? (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
Total: ${totalCost}
</Typography>
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs?.bitrate === "hd" ? "HD" : "standard"} quality
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {podcastMode} mode
</Typography>
</Typography>
</Box>
) : (
"Estimate unavailable until returned by the server."
)
</Box>
) : (
"Estimate unavailable. Pricing data not found."
)
}
arrow
placement="top"
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={totalCost ? `Est. $${totalCost}` : "Est. Unavailable"}
size="small"
sx={{
background: totalCost ? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)" : "rgba(100, 116, 139, 0.12)",
color: totalCost ? "#059669" : "#475569",
fontWeight: 600,
border: totalCost ? "1px solid rgba(16, 185, 129, 0.2)" : "1px solid rgba(100, 116, 139, 0.25)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
</Stack>
</Stack>
{/* Website input row - appears when user clicks "Your Website" chip */}
<Collapse in={showWebsiteInput}>
<Box sx={{ mt: 1.5, mb: 1.5, display: "flex", alignItems: "center", gap: 1 }}>
<TextField
fullWidth
size="small"
placeholder="https://yourdomain.com (enter your website home page)"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
disabled={isExtracting}
error={!!websiteError}
helperText={websiteError}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
fontSize: "0.875rem",
"&.Mui-focused": {
backgroundColor: "#ffffff",
},
},
}}
/>
<Button
variant="contained"
size="small"
onClick={async () => {
if (!websiteUrl.trim()) {
setWebsiteError("Please enter a website URL");
return;
}
setWebsiteError(null);
setIsExtracting(true);
try {
const result = await podcastApi.extractUrl({ url: websiteUrl.trim() });
if (result.success) {
const extractionData = {
title: result.title || "",
text: result.text || "",
summary: result.summary || "",
highlights: result.highlights || [],
url: result.url,
image: result.image || undefined,
favicon: result.favicon || undefined,
subpages: result.subpages || [],
};
_setExtractedData(extractionData);
// Save to backend for future use
try {
await podcastApi.saveWebsiteExtraction({
title: extractionData.title,
text: extractionData.text,
summary: extractionData.summary,
highlights: extractionData.highlights,
url: extractionData.url,
subpages: extractionData.subpages,
});
} catch (saveErr) {
console.warn("[TopicUrlInput] Failed to save extraction:", saveErr);
}
setShowPreviewModal(true);
} else {
setWebsiteError(result.error || "Failed to extract content");
}
} catch (err: any) {
setWebsiteError(err?.message || "Failed to extract content");
} finally {
setIsExtracting(false);
}
}}
disabled={isExtracting || !websiteUrl.trim()}
sx={{
textTransform: "none",
fontWeight: 600,
fontSize: "0.8125rem",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
whiteSpace: "nowrap",
"&:hover": {
background: "linear-gradient(135deg, #7c8ff0 0%, #8a5cb3 100%)",
},
}}
>
{isExtracting ? <CircularProgress size={16} sx={{ color: "#fff" }} /> : "Extract"}
</Button>
</Box>
</Collapse>
<Box sx={{ position: "relative" }}>
<Tooltip
title={
isListening
? "Listening... Click the mic to stop."
: isUrl
? "We detected a URL. We'll fetch insights from this page."
: "Enter a concise idea, paste a blog URL, or click the mic to speak your topic."
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 280,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={estimatedCost ? `Est. $${estimatedCost.total}` : "Est. Unavailable"}
<TextField
fullWidth
multiline
rows={5}
placeholder={!value ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}" or paste a URL` : ""}
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#1e293b",
},
}}
value={value}
onChange={(e) => onChange(e.target.value)}
size="small"
disabled={isListening}
helperText={
error
? error
: isListening
? "Listening... Speak your topic now."
: isUrl
? "URL detected. We'll analyze this page content."
: "Enter a clear, concise topic. You can also click the mic to speak."
}
sx={{
background: estimatedCost
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
: "rgba(100, 116, 139, 0.12)",
color: estimatedCost ? "#059669" : "#475569",
fontWeight: 600,
border: estimatedCost
? "1px solid rgba(16, 185, 129, 0.2)"
: "1px solid rgba(100, 116, 139, 0.25)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
"& .MuiOutlinedInput-root": {
backgroundColor: isListening ? "rgba(16, 185, 129, 0.04)" : "#f8fafc",
border: isListening ? "2px solid rgba(16, 185, 129, 0.5)" : "2px solid rgba(102, 126, 234, 0.2)",
borderRadius: 2,
fontSize: "1rem",
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: "#ffffff",
borderColor: isListening ? "rgba(16, 185, 129, 0.7)" : "rgba(102, 126, 234, 0.4)",
boxShadow: isListening ? "0 2px 8px rgba(16, 185, 129, 0.15)" : "0 2px 8px rgba(102, 126, 234, 0.1)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: isListening ? "#10b981" : isUrl ? "#10b981" : "#667eea",
borderWidth: 2,
boxShadow: isListening
? "0 0 0 4px rgba(16, 185, 129, 0.1)"
: isUrl
? "0 0 0 4px rgba(16, 185, 129, 0.1)"
: "0 0 0 4px rgba(102, 126, 234, 0.1)",
},
},
"& .MuiOutlinedInput-input": {
fontSize: "1rem",
lineHeight: 1.7,
color: "#1e293b",
fontWeight: 500,
"&::placeholder": {
color: "#64748b",
opacity: 1,
fontWeight: 400,
},
},
"& .MuiFormHelperText-root": {
color: error ? "#ef4444" : isListening ? "#059669" : isUrl ? "#059669" : "#64748b",
fontSize: "0.8125rem",
fontWeight: 500,
mt: 1,
},
}}
/>
</Tooltip>
</Stack>
<Tooltip
title={
isUrl
? "We detected a URL. We'll fetch insights from this page."
: "Enter a concise idea or paste a blog URL."
}
arrow
placement="top"
>
<TextField
fullWidth
multiline
rows={5}
placeholder={!value ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}" or paste a URL` : ""}
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#1e293b",
},
}}
value={value}
onChange={(e) => onChange(e.target.value)}
size="small"
helperText={
isUrl
? "URL detected. We'll analyze this page content."
: "Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
border: "2px solid rgba(102, 126, 234, 0.2)",
borderRadius: 2,
fontSize: "1rem",
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.1)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: isUrl ? "#10b981" : "#667eea",
borderWidth: 2,
boxShadow: isUrl
? "0 0 0 4px rgba(16, 185, 129, 0.1)"
: "0 0 0 4px rgba(102, 126, 234, 0.1)",
},
},
"& .MuiOutlinedInput-input": {
fontSize: "1rem",
lineHeight: 1.7,
color: "#1e293b",
fontWeight: 500,
"&::placeholder": {
color: "#64748b",
opacity: 1,
fontWeight: 400,
},
},
"& .MuiFormHelperText-root": {
color: isUrl ? "#059669" : "#64748b",
{/* Mic button with listening indicator - positioned inside the textarea bottom-right */}
{isSupported && !loading && (
<Box sx={{ position: "absolute", bottom: isListening ? 32 : 44, right: 4, zIndex: 2, display: "flex", alignItems: "center", gap: 1 }}>
{isListening && (
<Typography
sx={{
fontSize: "0.7rem",
fontWeight: 600,
color: "#059669",
backgroundColor: "rgba(16, 185, 129, 0.1)",
px: 1,
py: 0.25,
borderRadius: 1,
border: "1px solid rgba(16, 185, 129, 0.2)",
whiteSpace: "nowrap",
animation: "fadeIn 0.2s ease",
"@keyframes fadeIn": {
from: { opacity: 0, transform: "translateX(4px)" },
to: { opacity: 1, transform: "translateX(0)" },
},
}}
>
Listening...
</Typography>
)}
<IconButton
onClick={handleMicClick}
disabled={loading}
size="small"
sx={{
width: 36,
height: 36,
borderRadius: "50%",
background: isListening
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
color: isListening ? "#fff" : "#667eea",
border: isListening
? "2px solid rgba(16, 185, 129, 0.3)"
: "1px solid rgba(102, 126, 234, 0.25)",
boxShadow: isListening
? "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)"
: "0 2px 6px rgba(102, 126, 234, 0.15)",
animation: isListening ? "pulse-mic 1.5s ease-in-out infinite" : "none",
"&:hover": {
background: isListening
? "linear-gradient(135deg, #34d399 0%, #10b981 100%)"
: "linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%)",
transform: "scale(1.05)",
},
"&.Mui-disabled": {
background: "rgba(100, 116, 139, 0.08)",
color: "#94a3b8",
border: "1px solid rgba(100, 116, 139, 0.15)",
},
"@keyframes pulse-mic": {
"0%": { boxShadow: "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)" },
"50%": { boxShadow: "0 0 0 8px rgba(16, 185, 129, 0.08), 0 2px 12px rgba(16, 185, 129, 0.4)" },
"100%": { boxShadow: "0 0 0 4px rgba(16, 185, 129, 0.15), 0 2px 8px rgba(16, 185, 129, 0.3)" },
},
}}
>
{isListening ? (
<StopIcon sx={{ fontSize: "1.1rem" }} />
) : (
<MicIcon sx={{ fontSize: "1.1rem" }} />
)}
</IconButton>
</Box>
)}
</Box>
{/* Category Research Chips - News + Finance + Research Papers + Personal Website */}
{showAIDetailsButton && !isUrl && onCategoryResearchClick && (
<Box sx={{ display: "flex", justifyContent: "flex-start", mt: 1.5, gap: 1, flexWrap: "wrap" }}>
<Chip
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#667eea !important" }} /> : <NewspaperIcon sx={{ fontSize: "0.875rem !important" }} />}
label="News"
onClick={() => onCategoryResearchClick("news")}
disabled={categoryResearchLoading || loading}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
color: "#667eea",
border: "1px solid rgba(102, 126, 234, 0.3)",
fontWeight: 600,
fontSize: "0.8125rem",
fontWeight: 500,
mt: 1,
},
}}
/>
</Tooltip>
"&:hover": {
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%)",
transform: "scale(1.02)",
},
}}
/>
<Chip
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#10b981 !important" }} /> : <ShowChartIcon sx={{ fontSize: "0.875rem !important" }} />}
label="Finance"
onClick={() => onCategoryResearchClick("finance")}
disabled={categoryResearchLoading || loading}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
color: "#10b981",
border: "1px solid rgba(16, 185, 129, 0.3)",
fontWeight: 600,
fontSize: "0.8125rem",
"&:hover": {
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%)",
transform: "scale(1.02)",
},
}}
/>
<Chip
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#8b5cf6 !important" }} /> : <SchoolIcon sx={{ fontSize: "0.875rem !important" }} />}
label="Research Papers"
onClick={() => onCategoryResearchClick("research-paper")}
disabled={categoryResearchLoading || loading}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(124, 58, 237, 0.1) 100%)",
color: "#8b5cf6",
border: "1px solid rgba(139, 92, 246, 0.3)",
fontWeight: 600,
fontSize: "0.8125rem",
"&:hover": {
background: "linear-gradient(135deg, rgba(139, 92, 246, 0.2) 0%, rgba(124, 58, 237, 0.2) 100%)",
transform: "scale(1.02)",
},
}}
/>
<Chip
icon={categoryResearchLoading ? <CircularProgress size={14} sx={{ color: "#f59e0b !important" }} /> : <PublicIcon sx={{ fontSize: "0.875rem !important" }} />}
label="Personal Site"
onClick={() => onCategoryResearchClick("personal-site", value)}
disabled={categoryResearchLoading || loading}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%)",
color: "#f59e0b",
border: "1px solid rgba(245, 158, 11, 0.3)",
fontWeight: 600,
fontSize: "0.8125rem",
"&:hover": {
background: "linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.2) 100%)",
transform: "scale(1.02)",
},
}}
/>
</Box>
)}
{/* Enhance topic with AI button + Get Trending Topics - appears when user types (and not a URL) */}
{showAIDetailsButton && !isUrl && (
@@ -340,6 +750,32 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
</Typography>
)}
</Box>
{/* Website Preview Modal */}
<WebsitePreviewModal
open={showPreviewModal}
extractedData={_extractedData}
onClose={() => {
setShowPreviewModal(false);
setShowWebsiteInput(false);
setWebsiteUrl("");
}}
onUseTextOnly={() => {
if (extractedData?.summary) {
const newValue = extractedData.title
? `${extractedData.title}: ${extractedData.summary}`
: extractedData.summary;
onChange(newValue);
}
setShowPreviewModal(false);
setShowWebsiteInput(false);
setWebsiteUrl("");
}}
onAnalyzeContent={() => {
// Phase 2: Will trigger full website analysis
console.log("[TopicUrlInput] Analyze Content clicked - Phase 2 feature");
}}
/>
</Box>
);
};
};

View File

@@ -83,10 +83,11 @@ export const TrendingTopicsModal: React.FC<TrendingTopicsModalProps> = ({
if (result.success && result.data) {
setTrendsData(result.data as GoogleTrendsData);
} else {
setError(result.error || "Failed to fetch trends data");
setError(result.error || "Failed to fetch trends data. Google may be rate-limiting requests — please try again in a few minutes.");
}
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || "Failed to fetch trending topics");
const msg = err?.response?.data?.detail || err?.message || "Failed to fetch trending topics. Please try again later.";
setError(msg);
} finally {
setLoading(false);
}
@@ -113,6 +114,15 @@ export const TrendingTopicsModal: React.FC<TrendingTopicsModalProps> = ({
const regions = trendsData?.interest_by_region || [];
const relatedTopics = trendsData?.related_topics || { top: [], rising: [] };
const relatedQueries = trendsData?.related_queries || { top: [], rising: [] };
const hasAnyData = trendsData
&& (
trendsData.interest_over_time?.length > 0
|| trendsData.interest_by_region?.length > 0
|| trendsData.related_topics?.top?.length > 0
|| trendsData.related_topics?.rising?.length > 0
|| trendsData.related_queries?.top?.length > 0
|| trendsData.related_queries?.rising?.length > 0
);
return (
<Dialog
@@ -180,7 +190,30 @@ export const TrendingTopicsModal: React.FC<TrendingTopicsModalProps> = ({
</Alert>
)}
{!loading && trendsData && (
{!loading && trendsData && !hasAnyData && (
<Box sx={{ py: 4, textAlign: "center" }}>
<TrendingUpIcon sx={{ fontSize: 48, color: "#f59e0b", mb: 1 }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: "#0f172a", mb: 1 }}>
No trends data available
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mb: 2 }}>
Google Trends could not find data for &ldquo;{initialKeywords}&rdquo;.
{trendsData.error
? " This may be due to rate limiting — please try again in a few minutes."
: " The topic may be too specific. Try a broader keyword."}
</Typography>
<Button
variant="outlined"
size="small"
onClick={fetchTrends}
sx={{ textTransform: "none", borderColor: "#667eea", color: "#667eea" }}
>
Retry
</Button>
</Box>
)}
{!loading && trendsData && hasAnyData && (
<>
<Tabs
value={tabValue}

View File

@@ -0,0 +1,533 @@
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Stack,
Divider,
IconButton,
} from "@mui/material";
import {
Language as LanguageIcon,
PsychologyAlt as AnalyzeIcon,
CheckCircle as UseTextIcon,
Close as CloseIcon,
} from "@mui/icons-material";
const extractRootDomain = (url: string): string => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.replace(/^www\./, '');
return hostname;
} catch {
return "Website";
}
};
interface ExtractedData {
title?: string;
text?: string;
summary?: string;
highlights?: string[];
url: string;
image?: string;
favicon?: string;
subpages?: Array<{
id?: string;
title?: string;
url?: string;
summary?: string;
text?: string;
}>;
}
interface WebsitePreviewModalProps {
open: boolean;
extractedData: ExtractedData | null;
onClose: () => void;
onUseTextOnly: () => void;
onAnalyzeContent: () => void;
}
export const WebsitePreviewModal: React.FC<WebsitePreviewModalProps> = ({
open,
extractedData,
onClose,
onUseTextOnly,
onAnalyzeContent,
}) => {
if (!extractedData) return null;
const rootDomain = extractRootDomain(extractedData.url);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
backgroundColor: "#ffffff",
color: "#1e293b",
borderRadius: 3,
boxShadow: "0 8px 40px rgba(0, 0, 0, 0.12)",
maxWidth: "80%",
width: "80%",
},
}}
>
<DialogTitle
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
pb: 1,
borderBottom: "1px solid #e2e8f0",
background: "#f8fafc",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
{(extractedData.favicon || extractedData.image) ? (
<Box
component="img"
src={extractedData.favicon || extractedData.image}
alt={rootDomain}
sx={{
width: 40,
height: 40,
borderRadius: 2,
objectFit: "contain",
backgroundColor: "#ffffff",
border: "1px solid #e2e8f0",
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
}}
>
<LanguageIcon sx={{ color: "#ffffff", fontSize: "1.25rem" }} />
</Box>
)}
<Stack>
<Typography
variant="h6"
sx={{
fontWeight: 700,
color: "#0f172a",
fontSize: "1.25rem",
letterSpacing: "-0.01em",
}}
>
{rootDomain} Content Analysis
</Typography>
<Typography
variant="body2"
sx={{
color: "#64748b",
fontSize: "0.8125rem",
}}
>
Extracted content from your website
</Typography>
</Stack>
</Stack>
<IconButton
onClick={onClose}
size="small"
sx={{
color: "#64748b",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ pt: 3, pb: 2 }}>
{/* Title */}
{extractedData.title && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{
color: "#667eea",
fontWeight: 700,
fontSize: "0.6875rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
Company / Organization
</Typography>
<Typography
variant="h6"
sx={{
color: "#1e293b",
fontWeight: 700,
fontSize: "1.125rem",
lineHeight: 1.4,
mt: 0.5,
}}
>
{extractedData.title}
</Typography>
</Box>
)}
{/* Summary */}
{extractedData.summary && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{
color: "#667eea",
fontWeight: 700,
fontSize: "0.6875rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
About
</Typography>
<Box
sx={{
mt: 0.5,
p: 2,
backgroundColor: "#f1f5f9",
borderRadius: 2,
border: "1px solid #e2e8f0",
}}
>
<Typography
variant="body1"
sx={{
color: "#334155",
fontSize: "0.9375rem",
lineHeight: 1.7,
fontWeight: 500,
}}
>
{extractedData.summary.length > 800
? extractedData.summary.substring(0, 800) + "..."
: extractedData.summary}
</Typography>
</Box>
</Box>
)}
{/* Highlights */}
{extractedData.highlights && extractedData.highlights.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography
variant="overline"
sx={{
color: "#667eea",
fontWeight: 700,
fontSize: "0.6875rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
Key Highlights
</Typography>
<Stack spacing={1} sx={{ mt: 1 }}>
{extractedData.highlights.slice(0, 6).map((highlight, index) => (
<Box
key={index}
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1.5,
p: 1.5,
backgroundColor: "#fffbeb",
borderRadius: 1.5,
border: "1px solid #fed7aa",
}}
>
<Box
sx={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: "#10b981",
mt: 0.625,
flexShrink: 0,
}}
/>
<Typography
variant="body2"
sx={{
color: "#374151",
fontSize: "0.875rem",
lineHeight: 1.6,
fontWeight: 500,
}}
>
{highlight}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
<Divider sx={{ my: 2.5 }} />
{/* URL */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
backgroundColor: "#f8fafc",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<LanguageIcon sx={{ color: "#667eea", fontSize: "1rem" }} />
</Box>
<Box>
<Typography
variant="caption"
sx={{
color: "#94a3b8",
fontSize: "0.6875rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Source URL
</Typography>
<Typography
variant="body2"
sx={{
color: "#667eea",
fontSize: "0.8125rem",
fontWeight: 500,
wordBreak: "break-all",
}}
>
{extractedData.url}
</Typography>
</Box>
</Box>
{/* Image / Favicon Display */}
{(extractedData.image || extractedData.favicon) && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{
color: "#667eea",
fontWeight: 700,
fontSize: "0.6875rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
Site Image
</Typography>
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 2 }}>
{extractedData.favicon && (
<Box
component="img"
src={extractedData.favicon}
alt="Favicon"
sx={{
width: 32,
height: 32,
borderRadius: 1,
objectFit: "contain",
backgroundColor: "#f8fafc",
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
{extractedData.image && (
<Box
component="img"
src={extractedData.image}
alt="Site"
sx={{
maxWidth: 120,
maxHeight: 60,
borderRadius: 1,
objectFit: "contain",
backgroundColor: "#f8fafc",
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
</Box>
</Box>
)}
{/* Subpages Display */}
{extractedData.subpages && extractedData.subpages.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{
color: "#667eea",
fontWeight: 700,
fontSize: "0.6875rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
Subpages ({extractedData.subpages.length})
</Typography>
<Stack spacing={1.5} sx={{ mt: 1 }}>
{extractedData.subpages.slice(0, 4).map((subpage, index) => (
<Box
key={index}
sx={{
p: 1.5,
backgroundColor: "#f1f5f9",
borderRadius: 1.5,
border: "1px solid #e2e8f0",
}}
>
<Typography
variant="body2"
sx={{
color: "#1e293b",
fontWeight: 600,
fontSize: "0.8125rem",
}}
>
{subpage.title || subpage.url || `Page ${index + 1}`}
</Typography>
{subpage.summary && (
<Typography
variant="body2"
sx={{
color: "#64748b",
fontSize: "0.75rem",
mt: 0.5,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{subpage.summary}
</Typography>
)}
{subpage.url && (
<Typography
variant="caption"
sx={{
color: "#667eea",
fontSize: "0.6875rem",
mt: 0.5,
display: "block",
}}
>
{subpage.url}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}
</DialogContent>
<DialogActions
sx={{
px: 3,
py: 2.5,
borderTop: "1px solid #e2e8f0",
gap: 1.5,
backgroundColor: "#f8fafc",
}}
>
<Button
variant="outlined"
onClick={onClose}
sx={{
textTransform: "none",
fontWeight: 600,
color: "#64748b",
borderColor: "#cbd5e1",
px: 2,
py: 1,
"&:hover": {
borderColor: "#94a3b8",
backgroundColor: "#f1f5f9",
},
}}
>
Cancel
</Button>
<Button
variant="contained"
startIcon={<AnalyzeIcon sx={{ fontSize: "1rem" }} />}
onClick={onAnalyzeContent}
disabled
sx={{
textTransform: "none",
fontWeight: 600,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
opacity: 0.6,
px: 2,
py: 1,
"&:hover": {
background: "linear-gradient(135deg, #7c8ff0 0%, #8a5cb3 100%)",
},
}}
>
Analyze Content (Coming Soon)
</Button>
<Button
variant="contained"
startIcon={<UseTextIcon sx={{ fontSize: "1rem" }} />}
onClick={onUseTextOnly}
sx={{
textTransform: "none",
fontWeight: 600,
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
px: 2.5,
py: 1,
"&:hover": {
background: "linear-gradient(135deg, #34d399 0%, #10b981 100%)",
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.4)",
},
}}
>
Use Text Only
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -153,6 +153,7 @@ export interface GoogleTrendsData {
timeframe: string;
geo: string;
keywords: string[];
source?: string;
timestamp: string;
cached?: boolean;
error?: string;

View File

@@ -0,0 +1,145 @@
import React from "react";
import {
Box,
Button,
Stack,
Typography,
Collapse,
IconButton,
} from "@mui/material";
import {
ExpandLess,
ExpandMore,
AutoAwesome,
RestartAlt,
CheckCircle,
Close,
} from "@mui/icons-material";
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
export interface VoiceClonePanelProps {
showVoiceClonePanel: boolean;
voiceCreated: boolean;
redoingClone: boolean;
onTogglePanel: () => void;
onVoiceSet: () => void;
onCancelRedo: () => void;
onDoneWithVoice: () => void;
}
export const VoiceClonePanel: React.FC<VoiceClonePanelProps> = ({
showVoiceClonePanel,
voiceCreated,
redoingClone,
onTogglePanel,
onVoiceSet,
onCancelRedo,
onDoneWithVoice,
}) => {
return (
<Box sx={{ mt: 2 }}>
<Button
onClick={onTogglePanel}
startIcon={showVoiceClonePanel ? <ExpandLess /> : redoingClone ? <RestartAlt /> : <AutoAwesome />}
endIcon={showVoiceClonePanel ? <ExpandLess /> : <ExpandMore />}
sx={{
py: 2,
px: 3,
width: "100%",
background: showVoiceClonePanel
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "linear-gradient(135deg, #8B5CF6 0%, #EC4899 50%, #F59E0B 100%)",
border: showVoiceClonePanel
? "1px solid rgba(102, 126, 234, 0.5)"
: "none",
borderRadius: 2.5,
color: "#fff",
fontWeight: 700,
textTransform: "none",
fontSize: "0.95rem",
boxShadow: showVoiceClonePanel
? "0 4px 15px rgba(102, 126, 234, 0.35)"
: "0 4px 20px rgba(139, 92, 246, 0.4), 0 0 30px rgba(236, 72, 153, 0.2)",
"&:hover": {
background: "linear-gradient(135deg, #7C3AED 0%, #9333EA 50%, #D97706 100%)",
boxShadow: "0 6px 25px rgba(139, 92, 246, 0.5)",
transform: "translateY(-1px)",
},
transition: "all 0.3s ease",
}}
>
{redoingClone ? "Redo Voice Clone" : showVoiceClonePanel ? "Hide Voice Cloning" : "Create Your Voice Clone ✨"}
</Button>
<Collapse in={showVoiceClonePanel}>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 2,
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
border: "1px solid rgba(102, 126, 234, 0.2)",
boxShadow: "inset 0 1px 3px rgba(0,0,0,0.05)",
}}
>
<VoiceAvatarPlaceholder
domainName="Podcast"
onVoiceSet={onVoiceSet}
/>
{voiceCreated && (
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
border: "1px solid rgba(16, 185, 129, 0.3)",
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<CheckCircle sx={{ color: "#10b981", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: "#10b981", fontWeight: 700 }}>
{redoingClone ? "Voice Clone Updated!" : "Voice Clone Created Successfully!"}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: "#475569", mb: 1.5, fontSize: "0.875rem" }}>
{redoingClone ? "Your voice clone has been updated and will be used for your podcast." : "Your custom voice clone is ready and will be used for your podcast."}
</Typography>
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
<Button
onClick={onCancelRedo}
sx={{
color: "#64748b",
"&:hover": { color: "#1e293b", background: "rgba(0,0,0,0.04)" },
}}
>
Cancel
</Button>
<Button
variant="contained"
onClick={onDoneWithVoice}
sx={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
color: "#fff",
fontWeight: 600,
textTransform: "none",
px: 3,
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
"&:hover": {
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
},
}}
>
Done
</Button>
</Stack>
</Box>
)}
</Box>
</Collapse>
</Box>
);
};

View File

@@ -42,35 +42,20 @@ import {
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
import { getAuthTokenGetter, getApiUrl } from "../../api/client";
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
export type VoiceOption = {
id: string;
name: string;
personality?: string;
isCustom?: boolean;
previewUrl?: string;
gender?: "male" | "female";
category?: string;
};
export type VoiceAudioSettings = {
speed: number;
volume: number;
pitch: number;
emotion: string;
};
const DEFAULT_AUDIO_SETTINGS: VoiceAudioSettings = {
speed: 1.0,
volume: 1.0,
pitch: 0,
emotion: "neutral",
};
const EMOTION_OPTIONS = ["neutral", "happy", "sad", "angry", "fearful", "disgusted", "surprised"];
type GenderFilter = "all" | "male" | "female";
type CategoryFilter = string;
import { useVoicePreview } from "./useVoicePreview";
import { useVoiceFiltering } from "./useVoiceFiltering";
import { VoiceClonePanel } from "./VoiceClonePanel";
import {
VoiceOption,
VoiceAudioSettings,
DEFAULT_AUDIO_SETTINGS,
EMOTION_OPTIONS,
VOICE_PREVIEW_MAP,
CATEGORY_OPTIONS,
PREDEFINED_VOICES,
CategoryFilter,
VoiceSelectorGenderFilter,
} from "./voiceConstants";
interface VoiceSelectorProps {
value: string;
@@ -82,58 +67,6 @@ interface VoiceSelectorProps {
onAudioSettingsChange?: (settings: VoiceAudioSettings) => void;
}
const VOICE_SAMPLE_BASE = "/assets/voice-samples";
const VOICE_PREVIEW_MAP: Record<string, string> = {
Wise_Woman: `${VOICE_SAMPLE_BASE}/wise_woman.mp3`,
Friendly_Person: `${VOICE_SAMPLE_BASE}/friendly_person.mp3`,
Inspirational_girl: `${VOICE_SAMPLE_BASE}/inspirational_girl.mp3`,
Deep_Voice_Man: `${VOICE_SAMPLE_BASE}/deep_voice_man.mp3`,
Calm_Woman: `${VOICE_SAMPLE_BASE}/calm_woman.mp3`,
Casual_Guy: `${VOICE_SAMPLE_BASE}/casual_guy.mp3`,
Lively_Girl: `${VOICE_SAMPLE_BASE}/lively_girl.mp3`,
Patient_Man: `${VOICE_SAMPLE_BASE}/patient_man.mp3`,
Young_Knight: `${VOICE_SAMPLE_BASE}/young_knight.mp3`,
Determined_Man: `${VOICE_SAMPLE_BASE}/determined_man.mp3`,
Lovely_Girl: `${VOICE_SAMPLE_BASE}/lovely_girl.mp3`,
Decent_Boy: `${VOICE_SAMPLE_BASE}/decent_boy.mp3`,
Imposing_Manner: `${VOICE_SAMPLE_BASE}/imposing_manner.mp3`,
Elegant_Man: `${VOICE_SAMPLE_BASE}/elegant_man.mp3`,
Abbess: `${VOICE_SAMPLE_BASE}/abbess.mp3`,
Sweet_Girl_2: `${VOICE_SAMPLE_BASE}/sweet_girl.mp3`,
Exuberant_Girl: `${VOICE_SAMPLE_BASE}/exuberant_girl.mp3`,
};
const CATEGORY_OPTIONS: { value: CategoryFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "educational", label: "Educational" },
{ value: "marketing", label: "Marketing" },
{ value: "professional", label: "Professional" },
{ value: "creative", label: "Creative" },
{ value: "calming", label: "Calming" },
{ value: "motivational", label: "Motivational" },
];
const PREDEFINED_VOICES: VoiceOption[] = [
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content", previewUrl: VOICE_PREVIEW_MAP.Wise_Woman, gender: "female", category: "educational" },
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions", previewUrl: VOICE_PREVIEW_MAP.Friendly_Person, category: "marketing" },
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration", previewUrl: VOICE_PREVIEW_MAP.Inspirational_girl, gender: "female", category: "motivational" },
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics", previewUrl: VOICE_PREVIEW_MAP.Deep_Voice_Man, gender: "male", category: "professional" },
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics", previewUrl: VOICE_PREVIEW_MAP.Calm_Woman, gender: "female", category: "calming" },
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials", previewUrl: VOICE_PREVIEW_MAP.Casual_Guy, gender: "male", category: "marketing" },
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements", previewUrl: VOICE_PREVIEW_MAP.Lively_Girl, gender: "female", category: "marketing" },
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations", previewUrl: VOICE_PREVIEW_MAP.Patient_Man, gender: "male", category: "educational" },
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming", previewUrl: VOICE_PREVIEW_MAP.Young_Knight, gender: "male", category: "creative" },
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches", previewUrl: VOICE_PREVIEW_MAP.Determined_Man, gender: "male", category: "motivational" },
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling", previewUrl: VOICE_PREVIEW_MAP.Lovely_Girl, gender: "female", category: "creative" },
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials", previewUrl: VOICE_PREVIEW_MAP.Decent_Boy, gender: "male", category: "marketing" },
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content", previewUrl: VOICE_PREVIEW_MAP.Imposing_Manner, gender: "male", category: "professional" },
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content", previewUrl: VOICE_PREVIEW_MAP.Elegant_Man, gender: "male", category: "professional" },
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation", previewUrl: VOICE_PREVIEW_MAP.Abbess, gender: "female", category: "calming" },
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content", previewUrl: VOICE_PREVIEW_MAP.Sweet_Girl_2, gender: "female", category: "creative" },
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" },
];
export const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
@@ -147,7 +80,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
}) => {
const [voiceClone, setVoiceClone] = useState<VoiceCloneResponse | null>(null);
const [loadingVoiceClone, setLoadingVoiceClone] = useState(false);
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const [showVoiceClonePanel, setShowVoiceClonePanel] = useState(false);
const [voiceCreated, setVoiceCreated] = useState(false);
const [redoingClone, setRedoingClone] = useState(false);
@@ -157,12 +89,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
const [localAudioSettings, setLocalAudioSettings] = useState<VoiceAudioSettings>(
externalAudioSettings || { ...DEFAULT_AUDIO_SETTINGS }
);
const [genderFilter, setGenderFilter] = useState<GenderFilter>("all");
const [genderFilter, setGenderFilter] = useState<VoiceSelectorGenderFilter>("all");
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>("all");
const audioRef = useRef<HTMLAudioElement | null>(null);
const prevVoiceCloneIdRef = useRef<string | null>(null);
const fetchVoiceClone = async () => {
const { playingPreview, handlePreview, stopCurrentAudio } = useVoicePreview();
const isPreviewing = playingPreview !== null;
const { voiceOptions, filteredVoices } = useVoiceFiltering({
showVoiceClone,
voiceClone,
value,
genderFilter,
categoryFilter,
});
const fetchVoiceClone = useCallback(async () => {
try {
setLoadingVoiceClone(true);
const result = await getLatestVoiceClone();
@@ -174,36 +117,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
} finally {
setLoadingVoiceClone(false);
}
};
const voiceOptions = useMemo(() => {
const options: VoiceOption[] = [...PREDEFINED_VOICES];
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
options.unshift({
id: VOICE_CLONE_ID,
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
personality: "Your own voice - cloned from audio sample",
isCustom: true,
previewUrl: voiceClone.preview_audio_url,
});
}
return options;
}, [showVoiceClone, voiceClone]);
const filteredVoices = useMemo(() => {
const filtered = PREDEFINED_VOICES.filter(v => {
if (genderFilter !== "all" && v.gender !== genderFilter) return false;
if (categoryFilter !== "all" && v.category !== categoryFilter) return false;
return true;
});
if (value && value !== VOICE_CLONE_ID && !filtered.some(v => v.id === value)) {
const selected = PREDEFINED_VOICES.find(v => v.id === value);
if (selected) filtered.unshift(selected);
}
return filtered;
}, [genderFilter, categoryFilter, value]);
}, []);
useEffect(() => {
if (!showVoiceClone) return;
@@ -222,80 +136,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
}
}, [voiceClone]);
const stopCurrentAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current = null;
}
}, []);
const handlePreview = useCallback(async (voice: VoiceOption) => {
if (!voice.previewUrl) return;
if (playingPreview === voice.id) {
stopCurrentAudio();
setPlayingPreview(null);
return;
}
stopCurrentAudio();
setPlayingPreview(voice.id);
// Append auth token for endpoints that require it (e.g. /api/assets/)
let previewUrl = voice.previewUrl;
// Convert relative URLs to absolute (pointing to backend, not Vercel)
if (previewUrl.startsWith('/')) {
previewUrl = `${getApiUrl()}${previewUrl}`;
}
try {
const tokenGetter = getAuthTokenGetter();
if (tokenGetter) {
const token = await tokenGetter();
if (token && previewUrl.includes('/api/')) {
const separator = previewUrl.includes('?') ? '&' : '?';
previewUrl = `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
}
}
} catch (e) {
// Token retrieval failed — try URL without token
}
const audio = new Audio(previewUrl);
audioRef.current = audio;
audio.onerror = () => {
console.error("Failed to load voice preview audio:", voice.previewUrl);
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
};
audio.onended = () => {
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
};
audio.play().catch((err) => {
console.error("Failed to play voice preview:", err);
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
});
}, [playingPreview, stopCurrentAudio]);
useEffect(() => {
return () => {
stopCurrentAudio();
};
}, [stopCurrentAudio]);
const handleChange = (newValue: string) => {
if (newValue === VOICE_CLONE_ID && voiceClone?.success) {
onChange(voiceClone.custom_voice_id || VOICE_CLONE_ID);
@@ -358,8 +198,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
}
}, [showVoiceClonePanel]);
const isPreviewing = playingPreview !== null;
useEffect(() => {
if (externalAudioSettings) {
setLocalAudioSettings(externalAudioSettings);
@@ -738,7 +576,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
key={val}
label={label}
size="small"
onClick={() => setGenderFilter(val as GenderFilter)}
onClick={() => setGenderFilter(val as VoiceSelectorGenderFilter)}
variant={genderFilter === val ? "filled" : "outlined"}
sx={{
height: 22,
@@ -987,110 +825,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
)}
{(showVoiceClone && !voiceClone?.success) || redoingClone ? (
<Box sx={{ mt: 2 }}>
<Button
onClick={handleTogglePanel}
startIcon={showVoiceClonePanel ? <ExpandLess /> : redoingClone ? <RestartAlt /> : <AutoAwesome />}
endIcon={showVoiceClonePanel ? <ExpandLess /> : <ExpandMore />}
sx={{
py: 2,
px: 3,
width: "100%",
background: showVoiceClonePanel
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "linear-gradient(135deg, #8B5CF6 0%, #EC4899 50%, #F59E0B 100%)",
border: showVoiceClonePanel
? "1px solid rgba(102, 126, 234, 0.5)"
: "none",
borderRadius: 2.5,
color: "#fff",
fontWeight: 700,
textTransform: "none",
fontSize: "0.95rem",
boxShadow: showVoiceClonePanel
? "0 4px 15px rgba(102, 126, 234, 0.35)"
: "0 4px 20px rgba(139, 92, 246, 0.4), 0 0 30px rgba(236, 72, 153, 0.2)",
"&:hover": {
background: "linear-gradient(135deg, #7C3AED 0%, #9333EA 50%, #D97706 100%)",
boxShadow: "0 6px 25px rgba(139, 92, 246, 0.5)",
transform: "translateY(-1px)",
},
transition: "all 0.3s ease",
}}
>
{redoingClone ? "Redo Voice Clone" : showVoiceClonePanel ? "Hide Voice Cloning" : "Create Your Voice Clone ✨"}
</Button>
<Collapse in={showVoiceClonePanel}>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 2,
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
border: "1px solid rgba(102, 126, 234, 0.2)",
boxShadow: "inset 0 1px 3px rgba(0,0,0,0.05)",
}}
>
<VoiceAvatarPlaceholder
domainName="Podcast"
onVoiceSet={handleVoiceSet}
/>
{voiceCreated && (
<Box
sx={{
mt: 2,
p: 2,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)",
border: "1px solid rgba(16, 185, 129, 0.3)",
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<CheckCircle sx={{ color: "#10b981", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: "#10b981", fontWeight: 700 }}>
{redoingClone ? "Voice Clone Updated!" : "Voice Clone Created Successfully!"}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: "#475569", mb: 1.5, fontSize: "0.875rem" }}>
{redoingClone ? "Your voice clone has been updated and will be used for your podcast." : "Your custom voice clone is ready and will be used for your podcast."}
</Typography>
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
<Button
onClick={handleCancelRedo}
sx={{
color: "#64748b",
"&:hover": { color: "#1e293b", background: "rgba(0,0,0,0.04)" },
}}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handleDoneWithVoice}
sx={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
color: "#fff",
fontWeight: 600,
textTransform: "none",
px: 3,
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
"&:hover": {
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
},
}}
>
Done
</Button>
</Stack>
</Box>
)}
</Box>
</Collapse>
</Box>
<VoiceClonePanel
showVoiceClonePanel={showVoiceClonePanel}
voiceCreated={voiceCreated}
redoingClone={redoingClone}
onTogglePanel={handleTogglePanel}
onVoiceSet={handleVoiceSet}
onCancelRedo={handleCancelRedo}
onDoneWithVoice={handleDoneWithVoice}
/>
) : null}
{/* Voice Fine-tune Modal */}

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { VoiceOption, PREDEFINED_VOICES, VoiceSelectorGenderFilter, CategoryFilter } from "./voiceConstants";
import { VoiceCloneResponse } from "../../api/brandAssets";
import { VOICE_CLONE_ID } from "./VoiceSelector";
export interface UseVoiceFilteringParams {
showVoiceClone: boolean;
voiceClone: VoiceCloneResponse | null;
value: string;
genderFilter: VoiceSelectorGenderFilter;
categoryFilter: CategoryFilter;
}
export interface UseVoiceFilteringReturn {
voiceOptions: VoiceOption[];
filteredVoices: VoiceOption[];
}
export const useVoiceFiltering = ({
showVoiceClone,
voiceClone,
value,
genderFilter,
categoryFilter,
}: UseVoiceFilteringParams): UseVoiceFilteringReturn => {
const voiceOptions = useMemo(() => {
const options: VoiceOption[] = [...PREDEFINED_VOICES];
if (showVoiceClone && voiceClone?.success && voiceClone.custom_voice_id) {
options.unshift({
id: VOICE_CLONE_ID,
name: voiceClone.voice_name || voiceClone.custom_voice_id || "My Voice Clone",
personality: "Your own voice - cloned from audio sample",
isCustom: true,
previewUrl: voiceClone.preview_audio_url,
});
}
return options;
}, [showVoiceClone, voiceClone]);
const filteredVoices = useMemo(() => {
const filtered = PREDEFINED_VOICES.filter(v => {
if (genderFilter !== "all" && v.gender !== genderFilter) return false;
if (categoryFilter !== "all" && v.category !== categoryFilter) return false;
return true;
});
if (value && value !== VOICE_CLONE_ID && !filtered.some(v => v.id === value)) {
const selected = PREDEFINED_VOICES.find(v => v.id === value);
if (selected) filtered.unshift(selected);
}
return filtered;
}, [genderFilter, categoryFilter, value]);
return { voiceOptions, filteredVoices };
};

View File

@@ -0,0 +1,102 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { VoiceOption } from "./voiceConstants";
import { getAuthTokenGetter, getApiUrl } from "../../api/client";
export interface UseVoicePreviewReturn {
playingPreview: string | null;
handlePreview: (voice: VoiceOption) => Promise<void>;
stopCurrentAudio: () => void;
}
export const useVoicePreview = (): UseVoicePreviewReturn => {
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const stopCurrentAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current = null;
}
}, []);
const handlePreview = useCallback(async (voice: VoiceOption) => {
if (!voice.previewUrl) return;
if (playingPreview === voice.id) {
stopCurrentAudio();
setPlayingPreview(null);
return;
}
stopCurrentAudio();
setPlayingPreview(voice.id);
let previewUrl = voice.previewUrl;
// For local development with frontend dev server, don't prepend API URL
// The frontend serves static files from /public/ through webpack dev server
const isLocalDev = window.location.hostname === 'localhost' && !previewUrl.includes('/api/');
if (!isLocalDev && previewUrl.startsWith('/')) {
previewUrl = `${getApiUrl()}${previewUrl}`;
}
if (isLocalDev) {
console.log("[VoicePreview] Local dev - using relative URL:", previewUrl);
} else {
console.log("[VoicePreview] Full URL:", previewUrl);
}
try {
const tokenGetter = getAuthTokenGetter();
if (tokenGetter) {
const token = await tokenGetter();
if (token && previewUrl.includes('/api/')) {
const separator = previewUrl.includes('?') ? '&' : '?';
previewUrl = `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
}
}
} catch (e) {
// Token retrieval failed — try URL without token
}
const audio = new Audio(previewUrl);
audioRef.current = audio;
audio.onerror = () => {
console.error("Failed to load voice preview audio:", voice.previewUrl);
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
};
audio.onended = () => {
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
};
audio.play().catch((err) => {
console.error("Failed to play voice preview:", err);
if (audioRef.current === audio) {
audioRef.current = null;
}
setPlayingPreview(null);
});
}, [playingPreview, stopCurrentAudio]);
useEffect(() => {
return () => {
stopCurrentAudio();
};
}, [stopCurrentAudio]);
return {
playingPreview,
handlePreview,
stopCurrentAudio,
};
};

View File

@@ -0,0 +1,81 @@
export type VoiceOption = {
id: string;
name: string;
personality?: string;
isCustom?: boolean;
previewUrl?: string;
gender?: "male" | "female";
category?: string;
};
export type VoiceAudioSettings = {
speed: number;
volume: number;
pitch: number;
emotion: string;
};
export const DEFAULT_AUDIO_SETTINGS: VoiceAudioSettings = {
speed: 1.0,
volume: 1.0,
pitch: 0,
emotion: "neutral",
};
export const EMOTION_OPTIONS = ["neutral", "happy", "sad", "angry", "fearful", "disgusted", "surprised"];
export const VOICE_SAMPLE_BASE = "/assets/voice-samples";
export const VOICE_PREVIEW_MAP: Record<string, string> = {
Wise_Woman: `${VOICE_SAMPLE_BASE}/wise_woman.mp3`,
Friendly_Person: `${VOICE_SAMPLE_BASE}/friendly_person.mp3`,
Inspirational_girl: `${VOICE_SAMPLE_BASE}/inspirational_girl.mp3`,
Deep_Voice_Man: `${VOICE_SAMPLE_BASE}/deep_voice_man.mp3`,
Calm_Woman: `${VOICE_SAMPLE_BASE}/calm_woman.mp3`,
Casual_Guy: `${VOICE_SAMPLE_BASE}/casual_guy.mp3`,
Lively_Girl: `${VOICE_SAMPLE_BASE}/lively_girl.mp3`,
Patient_Man: `${VOICE_SAMPLE_BASE}/patient_man.mp3`,
Young_Knight: `${VOICE_SAMPLE_BASE}/young_knight.mp3`,
Determined_Man: `${VOICE_SAMPLE_BASE}/determined_man.mp3`,
Lovely_Girl: `${VOICE_SAMPLE_BASE}/lovely_girl.mp3`,
Decent_Boy: `${VOICE_SAMPLE_BASE}/decent_boy.mp3`,
Imposing_Manner: `${VOICE_SAMPLE_BASE}/imposing_manner.mp3`,
Elegant_Man: `${VOICE_SAMPLE_BASE}/elegant_man.mp3`,
Abbess: `${VOICE_SAMPLE_BASE}/abbess.mp3`,
Sweet_Girl_2: `${VOICE_SAMPLE_BASE}/sweet_girl.mp3`,
Exuberant_Girl: `${VOICE_SAMPLE_BASE}/exuberant_girl.mp3`,
};
export type CategoryFilter = string;
export const CATEGORY_OPTIONS: { value: CategoryFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "educational", label: "Educational" },
{ value: "marketing", label: "Marketing" },
{ value: "professional", label: "Professional" },
{ value: "creative", label: "Creative" },
{ value: "calming", label: "Calming" },
{ value: "motivational", label: "Motivational" },
];
export const PREDEFINED_VOICES: VoiceOption[] = [
{ id: "Wise_Woman", name: "Wise Woman", personality: "Authoritative, trustworthy female voice - perfect for educational content", previewUrl: VOICE_PREVIEW_MAP.Wise_Woman, gender: "female", category: "educational" },
{ id: "Friendly_Person", name: "Friendly Person", personality: "Warm, approachable voice - great for welcoming introductions", previewUrl: VOICE_PREVIEW_MAP.Friendly_Person, category: "marketing" },
{ id: "Inspirational_girl", name: "Inspirational Girl", personality: "Motivational, uplifting female voice - ideal for inspiration", previewUrl: VOICE_PREVIEW_MAP.Inspirational_girl, gender: "female", category: "motivational" },
{ id: "Deep_Voice_Man", name: "Deep Voice Man", personality: "Powerful, commanding male voice - excellent for serious topics", previewUrl: VOICE_PREVIEW_MAP.Deep_Voice_Man, gender: "male", category: "professional" },
{ id: "Calm_Woman", name: "Calm Woman", personality: "Soothing, composed female voice - perfect for meditation or sensitive topics", previewUrl: VOICE_PREVIEW_MAP.Calm_Woman, gender: "female", category: "calming" },
{ id: "Casual_Guy", name: "Casual Guy", personality: "Relaxed, conversational male voice - great for vlogs and tutorials", previewUrl: VOICE_PREVIEW_MAP.Casual_Guy, gender: "male", category: "marketing" },
{ id: "Lively_Girl", name: "Lively Girl", personality: "Energetic, enthusiastic female voice - ideal for exciting announcements", previewUrl: VOICE_PREVIEW_MAP.Lively_Girl, gender: "female", category: "marketing" },
{ id: "Patient_Man", name: "Patient Man", personality: "Gentle, understanding male voice - perfect for explanations", previewUrl: VOICE_PREVIEW_MAP.Patient_Man, gender: "male", category: "educational" },
{ id: "Young_Knight", name: "Young Knight", personality: "Brave, confident male voice - great for adventure and gaming", previewUrl: VOICE_PREVIEW_MAP.Young_Knight, gender: "male", category: "creative" },
{ id: "Determined_Man", name: "Determined Man", personality: "Strong, resolute male voice - excellent for motivational speeches", previewUrl: VOICE_PREVIEW_MAP.Determined_Man, gender: "male", category: "motivational" },
{ id: "Lovely_Girl", name: "Lovely Girl", personality: "Sweet, charming female voice - ideal for storytelling", previewUrl: VOICE_PREVIEW_MAP.Lovely_Girl, gender: "female", category: "creative" },
{ id: "Decent_Boy", name: "Decent Boy", personality: "Honest, sincere male voice - perfect for testimonials", previewUrl: VOICE_PREVIEW_MAP.Decent_Boy, gender: "male", category: "marketing" },
{ id: "Imposing_Manner", name: "Imposing Manner", personality: "Formal, dignified male voice - great for corporate content", previewUrl: VOICE_PREVIEW_MAP.Imposing_Manner, gender: "male", category: "professional" },
{ id: "Elegant_Man", name: "Elegant Man", personality: "Refined, sophisticated male voice - ideal for luxury content", previewUrl: VOICE_PREVIEW_MAP.Elegant_Man, gender: "male", category: "professional" },
{ id: "Abbess", name: "Abbess", personality: "Spiritual, serene female voice - perfect for meditation", previewUrl: VOICE_PREVIEW_MAP.Abbess, gender: "female", category: "calming" },
{ id: "Sweet_Girl_2", name: "Sweet Girl 2", personality: "Gentle, melodic female voice - excellent for children's content", previewUrl: VOICE_PREVIEW_MAP.Sweet_Girl_2, gender: "female", category: "creative" },
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" },
];
export type VoiceSelectorGenderFilter = "all" | "male" | "female";

View File

@@ -0,0 +1,150 @@
import { useState, useRef, useCallback, useEffect } from 'react';
export interface UseSpeechToTextReturn {
isRecording: boolean;
recordingSeconds: number;
audioBlob: Blob | null;
error: string | null;
isSupported: boolean;
startRecording: () => Promise<void>;
stopRecording: () => void;
reset: () => void;
}
const MAX_RECORDING_SECONDS = 60;
/**
* Reusable hook for recording audio from the browser microphone.
* Extracted and generalized from VoiceAvatarPlaceholder.tsx recording logic.
*/
export const useSpeechToText = (): UseSpeechToTextReturn => {
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [error, setError] = useState<string | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const isSupported = typeof window !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined';
const cleanup = useCallback(() => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
recorderRef.current = null;
chunksRef.current = [];
setIsRecording(false);
setRecordingSeconds(0);
}, []);
const stopRecording = useCallback(() => {
try {
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
recorderRef.current.stop();
} else {
cleanup();
}
} catch {
cleanup();
}
}, [cleanup]);
const startRecording = useCallback(async () => {
if (!isSupported) {
setError('Microphone is not supported in this browser.');
return;
}
setError(null);
setAudioBlob(null);
cleanup();
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
recorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = () => {
try {
const chunks = [...chunksRef.current];
const blob = new Blob(chunks, { type: mimeType });
setAudioBlob(blob);
} catch (err: any) {
setError('Failed to create audio recording. Please try again.');
} finally {
cleanup();
}
};
recorder.onerror = () => {
setError('Recording error occurred. Please try again.');
cleanup();
};
recorder.start();
setIsRecording(true);
setRecordingSeconds(0);
timerRef.current = window.setInterval(() => {
setRecordingSeconds((s) => {
const next = s + 1;
if (next >= MAX_RECORDING_SECONDS) {
stopRecording();
}
return next;
});
}, 1000);
} catch (e: any) {
setError(e?.message || 'Failed to access microphone');
cleanup();
}
}, [isSupported, cleanup, stopRecording]);
const reset = useCallback(() => {
setAudioBlob(null);
setError(null);
cleanup();
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
};
}, []);
return {
isRecording,
recordingSeconds,
audioBlob,
error,
isSupported,
startRecording,
stopRecording,
reset,
};
};

View File

@@ -392,7 +392,27 @@ export const podcastApi = {
};
},
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
async getWebsiteExtraction(): Promise<{ success: boolean; data?: any; error?: string }> {
const response = await aiApiClient.get("/api/podcast/website-extraction");
return response.data;
},
async saveWebsiteExtraction(data: any): Promise<{ success: boolean; message?: string; error?: string }> {
const response = await aiApiClient.post("/api/podcast/website-extraction", data);
return response.data;
},
async saveTopicContext(projectId: string, topicContext: any): Promise<{ success: boolean; message?: string; error?: string }> {
const response = await aiApiClient.post(`/api/podcast/project/${projectId}/topic-context`, topicContext);
return response.data;
},
async getTopicContext(projectId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const response = await aiApiClient.get(`/api/podcast/project/${projectId}/topic-context`);
return response.data;
},
async enhanceIdea(params: { idea: string; bible?: any; website_data?: any; topic_context?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> {
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
return response.data;
},
@@ -401,6 +421,7 @@ export const podcastApi = {
keywords: string[];
timeframe?: string;
geo?: string;
source?: string;
}): Promise<{
success: boolean;
data?: {
@@ -411,6 +432,7 @@ export const podcastApi = {
timeframe: string;
geo: string;
keywords: string[];
source: string;
cached: boolean;
};
error?: string;
@@ -419,6 +441,33 @@ export const podcastApi = {
keywords: params.keywords,
timeframe: params.timeframe || "today 12-m",
geo: params.geo || "US",
source: params.source || "web", // 'web' = Google, 'podcast' = YouTube
});
return response.data;
},
async extractUrl(params: { url: string }): Promise<{
success: boolean;
title?: string;
text?: string;
summary?: string;
highlights?: string[];
author?: string;
url: string;
image?: string;
favicon?: string;
subpages?: Array<{id: string; title: string; url: string; summary: string; text: string}>;
error?: string;
}> {
const response = await aiApiClient.post("/api/podcast/extract-url", params);
return response.data;
},
async transcribeAudio(audioBlob: Blob): Promise<{ text: string; error?: string }> {
const formData = new FormData();
formData.append("audio", audioBlob, `recording_${Date.now()}.webm`);
const response = await aiApiClient.post("/api/podcast/transcribe", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
},
@@ -1085,16 +1134,103 @@ export const podcastApi = {
return response.data;
},
async generateChartPreview(params: {
async generateChartPreview(params: {
chart_data: Record<string, any>;
chart_type: string;
title: string;
}): Promise<{ preview_url: string; chart_id: string }> {
// Canonical backend endpoint from api/podcast/handlers/broll.py after router prefix composition:
// /api/podcast (main router) + /broll (handler prefix) + /preview/chart (endpoint)
const response = await aiApiClient.post('/api/podcast/broll/preview/chart', params);
return response.data;
},
async researchByCategory(params: {
category: "news" | "finance" | "research-paper" | "personal-site";
keyword?: string;
maxResults?: number;
websiteUrl?: string;
}): Promise<{
success: boolean;
category: string;
provider: string;
topics: Array<{
title: string;
url: string;
snippet: string;
score: number;
favicon?: string;
}>;
query?: string;
error?: string;
}> {
const response = await aiApiClient.post('/api/podcast/research/tavily-category', {
category: params.category,
keyword: params.keyword,
max_results: params.maxResults,
website_url: params.websiteUrl,
});
return response.data;
},
async preEstimateCost(params: {
duration: number;
speakers: number;
queryCount: number;
podcastMode: string;
gemini_model?: string;
audio_tts_model?: string;
voice_clone_engine?: string;
image_model?: string;
video_model?: string;
}): Promise<{
estimate?: {
// Individual costs
analysisCost: number;
researchCost: number;
researchSearchCost: number;
researchLlmCost: number;
scriptCost: number;
ttsCost: number;
voiceCloneCost: number;
avatarCost: number;
videoCost: number;
total: number;
// Category totals
llmCost: number;
audioCost: number;
mediaCost: number;
// Metadata
currency: string;
source: string;
models: {
llm: string;
research: string;
audio_tts: string;
voice_clone: string;
image: string;
video: string;
};
assumptions: Record<string, number>;
} | null;
error?: string | null;
pricing_available?: boolean;
debug?: {
pricing_rows: number;
providers: string[];
};
}> {
const response = await aiApiClient.post('/api/podcast/pre-estimate', {
duration: params.duration,
speakers: params.speakers,
query_count: params.queryCount,
podcast_mode: params.podcastMode,
gemini_model: params.gemini_model,
audio_tts_model: params.audio_tts_model,
voice_clone_engine: params.voice_clone_engine,
image_model: params.image_model,
video_model: params.video_model,
});
return response.data;
},
};
export type PodcastApi = typeof podcastApi;