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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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 “{initialKeywords}”.
|
||||
{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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -153,6 +153,7 @@ export interface GoogleTrendsData {
|
||||
timeframe: string;
|
||||
geo: string;
|
||||
keywords: string[];
|
||||
source?: string;
|
||||
timestamp: string;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
|
||||
145
frontend/src/components/shared/VoiceClonePanel.tsx
Normal file
145
frontend/src/components/shared/VoiceClonePanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
|
||||
56
frontend/src/components/shared/useVoiceFiltering.ts
Normal file
56
frontend/src/components/shared/useVoiceFiltering.ts
Normal 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 };
|
||||
};
|
||||
102
frontend/src/components/shared/useVoicePreview.ts
Normal file
102
frontend/src/components/shared/useVoicePreview.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
81
frontend/src/components/shared/voiceConstants.ts
Normal file
81
frontend/src/components/shared/voiceConstants.ts
Normal 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";
|
||||
150
frontend/src/hooks/useSpeechToText.ts
Normal file
150
frontend/src/hooks/useSpeechToText.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user