feat: Improve podcast maker UX and fix bugs
Frontend: - Add progress modals with educational content for analysis and voice cloning - Improve tab navigation in AnalysisPanel (combine Titles, Hook, CTA into one tab) - Fix tab styling to make inactive tabs visible - Fix avatar 'Make Presentable' not updating preview (blob URL handling) - Improve mobile responsiveness for avatar tabs - Clean up verbose console logging (AnalysisPanel, demoMode, RobustCamera) - Add sequential progress messages instead of cycling Backend: - Fix 'Depends object has no attribute get' error in auth and image editing - Use get_session_for_user instead of get_db outside FastAPI DI context - Reduce WARNING logs to DEBUG in audio handler - Add proper emphasis boolean handling in script generation - Add missing fields to PodcastScene and PodcastSceneLine models - Fix voice cloning cost estimate display issue
This commit is contained in:
@@ -391,9 +391,9 @@ async def serve_podcast_audio(
|
|||||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
user_id = require_authenticated_user(current_user)
|
user_id = require_authenticated_user(current_user)
|
||||||
logger.warning(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}")
|
logger.debug(f"[Podcast] serve_podcast_audio called: user_id={user_id}, filename={filename}")
|
||||||
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
|
audio_path = _resolve_podcast_media_file(filename, "audio", user_id)
|
||||||
logger.warning(f"[Podcast] Resolved audio path: {audio_path}")
|
logger.debug(f"[Podcast] Resolved audio path: {audio_path}")
|
||||||
|
|
||||||
return FileResponse(audio_path, media_type="audio/mpeg")
|
return FileResponse(audio_path, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ async def make_avatar_presentable(
|
|||||||
Transform an uploaded avatar image into a podcast-appropriate presenter.
|
Transform an uploaded avatar image into a podcast-appropriate presenter.
|
||||||
Uses AI image editing to convert the uploaded photo into a professional podcast presenter.
|
Uses AI image editing to convert the uploaded photo into a professional podcast presenter.
|
||||||
"""
|
"""
|
||||||
|
# CRITICAL: Log at the very start before any logic
|
||||||
|
logger.info(f"[Podcast] ===== MAKE PRESENTABLE ENDPOINT START =====")
|
||||||
|
|
||||||
user_id = require_authenticated_user(current_user)
|
user_id = require_authenticated_user(current_user)
|
||||||
logger.info(f"[Podcast] Make presentable request received - user_id={user_id}, avatar_url={avatar_url}, project_id={project_id}")
|
logger.info(f"[Podcast] Make presentable request received - user_id={user_id}, avatar_url={avatar_url}, project_id={project_id}")
|
||||||
|
|
||||||
|
|||||||
@@ -178,25 +178,83 @@ COST OPTIMIZATION:
|
|||||||
scenes_data = data.get("scenes") or []
|
scenes_data = data.get("scenes") or []
|
||||||
if not isinstance(scenes_data, list):
|
if not isinstance(scenes_data, list):
|
||||||
raise HTTPException(status_code=500, detail="LLM response missing scenes array")
|
raise HTTPException(status_code=500, detail="LLM response missing scenes array")
|
||||||
|
|
||||||
|
if len(scenes_data) == 0:
|
||||||
|
logger.warning("[ScriptGen] LLM returned empty scenes array")
|
||||||
|
raise HTTPException(status_code=500, detail="LLM returned no scenes - please try again")
|
||||||
|
|
||||||
|
logger.warning(f"[ScriptGen] Processing {len(scenes_data)} scenes from LLM response")
|
||||||
|
|
||||||
valid_emotions = {"neutral", "happy", "excited", "serious", "curious", "confident"}
|
valid_emotions = {"neutral", "happy", "excited", "serious", "curious", "confident"}
|
||||||
|
|
||||||
# Normalize scenes
|
# Normalize scenes
|
||||||
scenes: list[PodcastScene] = []
|
scenes: list[PodcastScene] = []
|
||||||
|
total_lines_input = 0
|
||||||
|
total_lines_output = 0
|
||||||
|
dropped_empty_lines = 0
|
||||||
|
|
||||||
for idx, scene in enumerate(scenes_data):
|
for idx, scene in enumerate(scenes_data):
|
||||||
|
if not isinstance(scene, dict):
|
||||||
|
logger.warning(f"[ScriptGen] Scene {idx} is not a dict, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
title = scene.get("title") or f"Scene {idx + 1}"
|
title = scene.get("title") or f"Scene {idx + 1}"
|
||||||
duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data))))
|
duration = int(scene.get("duration") or max(30, (request.duration_minutes * 60) // max(1, len(scenes_data))))
|
||||||
emotion = scene.get("emotion") or "neutral"
|
emotion = scene.get("emotion") or "neutral"
|
||||||
if emotion not in valid_emotions:
|
if emotion not in valid_emotions:
|
||||||
|
logger.warning(f"[ScriptGen] Invalid emotion '{emotion}' in scene {idx}, defaulting to 'neutral'")
|
||||||
emotion = "neutral"
|
emotion = "neutral"
|
||||||
lines_raw = scene.get("lines") or []
|
lines_raw = scene.get("lines") or []
|
||||||
|
total_lines_input += len(lines_raw)
|
||||||
lines: list[PodcastSceneLine] = []
|
lines: list[PodcastSceneLine] = []
|
||||||
for line in lines_raw:
|
|
||||||
|
for line_idx, line in enumerate(lines_raw):
|
||||||
|
if not isinstance(line, dict):
|
||||||
|
logger.warning(f"[ScriptGen] Line {line_idx} in scene {idx} is not a dict, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest")
|
speaker = line.get("speaker") or ("Host" if len(lines) % request.speakers == 0 else "Guest")
|
||||||
text = line.get("text") or ""
|
text = line.get("text") or ""
|
||||||
emphasis = line.get("emphasis", False)
|
|
||||||
|
# Handle emphasis - convert various values to boolean
|
||||||
|
emphasis_raw = line.get("emphasis", False)
|
||||||
|
if isinstance(emphasis_raw, bool):
|
||||||
|
emphasis = emphasis_raw
|
||||||
|
elif isinstance(emphasis_raw, str):
|
||||||
|
emphasis = emphasis_raw.lower() in ("true", "yes", "1")
|
||||||
|
if emphasis_raw.lower() not in ("true", "false", "yes", "no", "1", "0"):
|
||||||
|
logger.debug(f"[ScriptGen] Unusual emphasis value '{emphasis_raw}' converted to {emphasis}")
|
||||||
|
else:
|
||||||
|
emphasis = bool(emphasis_raw)
|
||||||
|
|
||||||
|
# Generate line ID if not provided
|
||||||
|
line_id = line.get("id") or f"line-{idx + 1}-{line_idx + 1}"
|
||||||
|
|
||||||
|
# Get used fact IDs if provided
|
||||||
|
used_fact_ids = line.get("usedFactIds") or line.get("used_fact_ids") or None
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
lines.append(PodcastSceneLine(speaker=speaker, text=text, emphasis=emphasis))
|
lines.append(PodcastSceneLine(
|
||||||
|
speaker=speaker,
|
||||||
|
text=text,
|
||||||
|
emphasis=emphasis,
|
||||||
|
id=line_id,
|
||||||
|
usedFactIds=used_fact_ids
|
||||||
|
))
|
||||||
|
total_lines_output += 1
|
||||||
|
else:
|
||||||
|
dropped_empty_lines += 1
|
||||||
|
logger.debug(f"[ScriptGen] Dropped empty line {line_idx} in scene {idx}")
|
||||||
|
|
||||||
|
# Log scene status
|
||||||
|
if scenes_data and isinstance(scene, dict):
|
||||||
|
image_url_raw = scene.get("imageUrl") or scene.get("image_url")
|
||||||
|
audio_url_raw = scene.get("audioUrl") or scene.get("audio_url")
|
||||||
|
if image_url_raw:
|
||||||
|
logger.warning(f"[ScriptGen] Scene {idx} has imageUrl - will be reset to None")
|
||||||
|
if audio_url_raw:
|
||||||
|
logger.warning(f"[ScriptGen] Scene {idx} has audioUrl - will be reset to None")
|
||||||
|
|
||||||
scenes.append(
|
scenes.append(
|
||||||
PodcastScene(
|
PodcastScene(
|
||||||
id=scene.get("id") or f"scene-{idx + 1}",
|
id=scene.get("id") or f"scene-{idx + 1}",
|
||||||
@@ -205,8 +263,16 @@ COST OPTIMIZATION:
|
|||||||
lines=lines,
|
lines=lines,
|
||||||
approved=False,
|
approved=False,
|
||||||
emotion=emotion,
|
emotion=emotion,
|
||||||
|
imageUrl=None, # Will be generated later
|
||||||
|
audioUrl=None, # Will be generated later
|
||||||
|
imagePrompt=None, # Will be generated during image generation
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Summary logging
|
||||||
|
logger.warning(f"[ScriptGen] Script generated: {len(scenes)} scenes, {total_lines_output}/{total_lines_input} lines")
|
||||||
|
if dropped_empty_lines > 0:
|
||||||
|
logger.warning(f"[ScriptGen] Dropped {dropped_empty_lines} empty lines")
|
||||||
|
|
||||||
return PodcastScriptResponse(scenes=scenes)
|
return PodcastScriptResponse(scenes=scenes)
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class PodcastSceneLine(BaseModel):
|
|||||||
speaker: str
|
speaker: str
|
||||||
text: str
|
text: str
|
||||||
emphasis: Optional[bool] = False
|
emphasis: Optional[bool] = False
|
||||||
|
id: Optional[str] = None # Optional line ID for frontend tracking
|
||||||
|
usedFactIds: Optional[List[str]] = None # Facts referenced in this line
|
||||||
|
|
||||||
|
|
||||||
class PodcastScene(BaseModel):
|
class PodcastScene(BaseModel):
|
||||||
@@ -111,6 +113,8 @@ class PodcastScene(BaseModel):
|
|||||||
approved: bool = False
|
approved: bool = False
|
||||||
emotion: Optional[str] = None
|
emotion: Optional[str] = None
|
||||||
imageUrl: Optional[str] = None # Generated image URL for video generation
|
imageUrl: Optional[str] = None # Generated image URL for video generation
|
||||||
|
audioUrl: Optional[str] = None # Generated audio URL for this scene
|
||||||
|
imagePrompt: Optional[str] = None # Original image generation prompt for video context
|
||||||
|
|
||||||
|
|
||||||
class PodcastExaConfig(BaseModel):
|
class PodcastExaConfig(BaseModel):
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ def require_authenticated_user(current_user: Dict[str, Any] | None) -> str:
|
|||||||
Validates the current user dictionary provided by Clerk middleware and
|
Validates the current user dictionary provided by Clerk middleware and
|
||||||
returns the normalized user_id. Raises HTTP 401 if authentication fails.
|
returns the normalized user_id. Raises HTTP 401 if authentication fails.
|
||||||
"""
|
"""
|
||||||
if not current_user or not isinstance(current_user, dict):
|
# Guard against dependency injection issues where Depends object might be passed
|
||||||
|
if current_user is None or not isinstance(current_user, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||||
|
|
||||||
|
# Additional check: ensure it's actually a dict and not a Depends object or other type
|
||||||
|
if not hasattr(current_user, 'get') or not callable(getattr(current_user, 'get')):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication context")
|
||||||
|
|
||||||
user_id = str(current_user.get("id", "")).strip()
|
user_id = str(current_user.get("id", "")).strip()
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def edit_image(
|
|||||||
skip_validation = os.getenv("ALWRITY_SKIP_IMAGE_EDITING_VALIDATION", "false").lower() in ("true", "1", "yes")
|
skip_validation = os.getenv("ALWRITY_SKIP_IMAGE_EDITING_VALIDATION", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
if user_id and not skip_validation:
|
if user_id and not skip_validation:
|
||||||
from services.database import get_db
|
from services.database import get_session_for_user
|
||||||
from services.subscription import PricingService
|
from services.subscription import PricingService
|
||||||
from services.subscription.preflight_validator import validate_image_editing_operations
|
from services.subscription.preflight_validator import validate_image_editing_operations
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -115,17 +115,18 @@ def edit_image(
|
|||||||
|
|
||||||
db = None
|
db = None
|
||||||
try:
|
try:
|
||||||
# Properly handle the generator
|
# Use get_session_for_user instead of get_db() since we're outside FastAPI DI
|
||||||
db_gen = get_db()
|
db = get_session_for_user(user_id)
|
||||||
db = next(db_gen)
|
if not db:
|
||||||
|
logger.warning(f"[Image Editing] ⚠️ Could not get DB session for user {user_id} - skipping validation")
|
||||||
pricing_service = PricingService(db)
|
else:
|
||||||
# Raises HTTPException immediately if validation fails - frontend gets immediate response
|
pricing_service = PricingService(db)
|
||||||
validate_image_editing_operations(
|
# Raises HTTPException immediately if validation fails - frontend gets immediate response
|
||||||
pricing_service=pricing_service,
|
validate_image_editing_operations(
|
||||||
user_id=user_id
|
pricing_service=pricing_service,
|
||||||
)
|
user_id=user_id
|
||||||
logger.info(f"[Image Editing] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image editing")
|
)
|
||||||
|
logger.info(f"[Image Editing] ✅ Pre-flight validation passed for user_id={user_id} - proceeding with image editing")
|
||||||
except HTTPException as http_ex:
|
except HTTPException as http_ex:
|
||||||
# Re-raise immediately - don't proceed with API call
|
# Re-raise immediately - don't proceed with API call
|
||||||
logger.error(f"[Image Editing] ❌ Pre-flight validation failed for user_id={user_id} - blocking API call: {http_ex.detail}")
|
logger.error(f"[Image Editing] ❌ Pre-flight validation failed for user_id={user_id} - blocking API call: {http_ex.detail}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop } from '@mui/material';
|
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop, LinearProgress } from '@mui/material';
|
||||||
import { keyframes } from '@mui/system';
|
import { keyframes } from '@mui/system';
|
||||||
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo } from '@mui/icons-material';
|
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver } from '@mui/icons-material';
|
||||||
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
|
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
|
||||||
import { OperationButton } from '../../../shared/OperationButton';
|
import { OperationButton } from '../../../shared/OperationButton';
|
||||||
|
|
||||||
@@ -11,6 +11,38 @@ const pulse = keyframes`
|
|||||||
100% { transform: scale(1); }
|
100% { transform: scale(1); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Sequential educational messages - displayed one after another during cloning
|
||||||
|
const VOICE_CLONE_PROGRESS_MESSAGES = [
|
||||||
|
{ title: "Audio Analysis", message: "Extracting audio features from your sample recording..." },
|
||||||
|
{ title: "Voice Fingerprint", message: "Creating a unique voice fingerprint with 100+ characteristics..." },
|
||||||
|
{ title: "Neural Training", message: "Training neural networks to understand your voice patterns..." },
|
||||||
|
{ title: "Prosody Mapping", message: "Mapping rhythm, stress, and intonation for natural speech..." },
|
||||||
|
{ title: "Voice Synthesis", message: "Building the text-to-speech engine with your voice model..." },
|
||||||
|
{ title: "Quality Assurance", message: "Validating audio quality and natural voice characteristics..." },
|
||||||
|
{ title: "Final Touches", message: "Optimizing for clarity and preparing your voice clone..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VOICE_USE_CASES = [
|
||||||
|
{ icon: <Podcasts />, title: "Podcasts", description: "Episode intros, narration, and voice-overs" },
|
||||||
|
{ icon: <Article />, title: "Blog to Audio", description: "Convert articles into engaging audio" },
|
||||||
|
{ icon: <VideoLibrary />, title: "YouTube Videos", description: "Video voice-overs and tutorials" },
|
||||||
|
{ icon: <Headphones />, title: "Audio Content", description: "Audiobooks, courses, and guides" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BRAND_VOICE_BENEFITS = [
|
||||||
|
{ icon: <RecordVoiceOver />, title: "Brand Consistency", description: "Same voice across all content channels" },
|
||||||
|
{ icon: <TrendingUp />, title: "Time Efficient", description: "Hours of audio from minutes of recording" },
|
||||||
|
{ icon: <CheckCircle />, title: "Professional Quality", description: "Studio-quality output without studio costs" },
|
||||||
|
{ icon: <AutoAwesome />, title: "Instant Generation", description: "Generate speech from text instantly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WHY_BRAND_VOICE_MATTERS = [
|
||||||
|
"Studies show consistent audio branding increases brand recognition by 80%",
|
||||||
|
"Voice cloning saves an average of 15+ hours per month vs traditional recording",
|
||||||
|
"Professional voice actors cost $200-500/hour – your clone is always available",
|
||||||
|
"Consistent voice builds trust and authority with your audience",
|
||||||
|
];
|
||||||
|
|
||||||
export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?: () => void }> = ({ domainName, onVoiceSet }) => {
|
export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?: () => void }> = ({ domainName, onVoiceSet }) => {
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||||
@@ -31,8 +63,9 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
const [voiceDescription, setVoiceDescription] = useState('');
|
const [voiceDescription, setVoiceDescription] = useState('');
|
||||||
|
|
||||||
// Debounce text inputs for token calculation to prevent button flickering
|
// Debounce text inputs for token calculation to prevent button flickering
|
||||||
const [debouncedPreviewText, setDebouncedPreviewText] = useState(previewText);
|
// Initialize with the actual default values, not the state variables (to avoid closure issues)
|
||||||
const [debouncedVoiceDescription, setDebouncedVoiceDescription] = useState(voiceDescription);
|
const [debouncedPreviewText, setDebouncedPreviewText] = useState('Hello! Welcome to Alwrity! This is a preview of your cloned voice. I hope you enjoy it!');
|
||||||
|
const [debouncedVoiceDescription, setDebouncedVoiceDescription] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
@@ -50,6 +83,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
|
|
||||||
const [cloning, setCloning] = useState(false);
|
const [cloning, setCloning] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [progressMessageIndex, setProgressMessageIndex] = useState(0);
|
||||||
const STORAGE_KEY = 'voice_clone_result_url';
|
const STORAGE_KEY = 'voice_clone_result_url';
|
||||||
const STORAGE_BACKUP_KEY = 'voice_clone_result_url_backup';
|
const STORAGE_BACKUP_KEY = 'voice_clone_result_url_backup';
|
||||||
|
|
||||||
@@ -179,6 +213,23 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
}
|
}
|
||||||
}, [success, error]);
|
}, [success, error]);
|
||||||
|
|
||||||
|
// Cycle progress messages during cloning - sequential, not repeating
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cloning) {
|
||||||
|
setProgressMessageIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgressMessageIndex((prev) => {
|
||||||
|
if (prev < VOICE_CLONE_PROGRESS_MESSAGES.length - 1) {
|
||||||
|
return prev + 1;
|
||||||
|
}
|
||||||
|
return prev; // Stay at last message
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [cloning]);
|
||||||
|
|
||||||
const handleSetAsBrandVoice = async () => {
|
const handleSetAsBrandVoice = async () => {
|
||||||
if (!resultAudioUrl) return;
|
if (!resultAudioUrl) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -1183,6 +1234,165 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Voice Cloning Progress Modal */}
|
||||||
|
<Modal
|
||||||
|
open={cloning}
|
||||||
|
closeAfterTransition
|
||||||
|
BackdropComponent={Backdrop}
|
||||||
|
BackdropProps={{ timeout: 500 }}
|
||||||
|
>
|
||||||
|
<Fade in={cloning}>
|
||||||
|
<Box sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: { xs: '95%', sm: '90%', md: 520 },
|
||||||
|
maxWidth: '95vw',
|
||||||
|
bgcolor: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
|
||||||
|
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
|
||||||
|
borderRadius: { xs: '16px', md: '24px' },
|
||||||
|
boxShadow: 24,
|
||||||
|
p: { xs: 2, sm: 2.5, md: 3 },
|
||||||
|
outline: 'none',
|
||||||
|
maxHeight: { xs: '90vh', md: '85vh' },
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Progress Header */}
|
||||||
|
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||||
|
<Box sx={{ position: 'relative', display: 'inline-flex', mb: 1.5 }}>
|
||||||
|
<CircularProgress size={60} thickness={3} sx={{ color: '#7C3AED' }} />
|
||||||
|
<Box sx={{ position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<GraphicEq sx={{ color: '#7C3AED', fontSize: 24 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ color: '#a78bfa', fontWeight: 600 }}>
|
||||||
|
{VOICE_CLONE_PROGRESS_MESSAGES[Math.min(progressMessageIndex, VOICE_CLONE_PROGRESS_MESSAGES.length - 1)].title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sequential Progress Steps */}
|
||||||
|
<Box sx={{ width: '100%', px: 1 }}>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{VOICE_CLONE_PROGRESS_MESSAGES.slice(0, progressMessageIndex + 1).map((msg, idx) => {
|
||||||
|
const isCompleted = idx < progressMessageIndex;
|
||||||
|
const isCurrent = idx === progressMessageIndex;
|
||||||
|
return (
|
||||||
|
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: isCompleted ? '#10b981' : isCurrent ? '#7C3AED' : 'rgba(255,255,255,0.1)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle sx={{ fontSize: 14, color: '#fff' }} />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<CircularProgress size={12} sx={{ color: '#fff' }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.3)' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
color: isCompleted ? 'rgba(255,255,255,0.5)' : isCurrent ? '#a78bfa' : 'rgba(255,255,255,0.4)',
|
||||||
|
fontWeight: isCurrent ? 600 : 400,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
textDecoration: isCompleted ? 'line-through' : 'none',
|
||||||
|
}}>
|
||||||
|
{msg.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'rgba(124, 58, 237, 0.2)',
|
||||||
|
'& .MuiLinearProgress-bar': { bgcolor: '#7C3AED', borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||||
|
|
||||||
|
{/* Use Cases Section */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.65rem', mb: 1, display: 'block' }}>
|
||||||
|
Where You'll Use Your Voice
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
{VOICE_USE_CASES.map((useCase, idx) => (
|
||||||
|
<Grid item xs={6} key={idx}>
|
||||||
|
<Box sx={{ p: 1, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.05)', height: '100%' }}>
|
||||||
|
<Box sx={{ color: '#7C3AED', mb: 0.5, fontSize: '1.25rem' }}>{useCase.icon}</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, display: 'block', fontSize: '0.75rem' }}>
|
||||||
|
{useCase.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.65rem', lineHeight: 1.3 }}>
|
||||||
|
{useCase.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.65rem', mb: 1, display: 'block' }}>
|
||||||
|
Why Brand Voice Matters
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{BRAND_VOICE_BENEFITS.map((benefit, idx) => (
|
||||||
|
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{ color: '#10b981', mt: 0.25, fontSize: 16 }}>{benefit.icon}</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, fontSize: '0.75rem' }}>
|
||||||
|
{benefit.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.7rem', display: 'block' }}>
|
||||||
|
{benefit.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Marketing Insights */}
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: 'rgba(124, 58, 237, 0.15)', border: '1px solid rgba(124, 58, 237, 0.3)' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#a78bfa', fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||||
|
💡 Did You Know?
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{WHY_BRAND_VOICE_MATTERS.slice(0, 2).map((fact, idx) => (
|
||||||
|
<Typography key={idx} variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.7rem', lineHeight: 1.5 }}>
|
||||||
|
• {fact}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', textAlign: 'center', fontSize: '0.7rem' }}>
|
||||||
|
This usually takes 10-30 seconds depending on your sample length
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Stack, Box, Typography, Divider, Chip, alpha, Button } from "@mui/material";
|
import { Stack, Box, Typography, Divider, Chip, alpha, Button } from "@mui/material";
|
||||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, EditNote as EditNoteIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, RecordVoiceOver as VoiceIcon, Lightbulb as TipsIcon, Quiz as TalkIcon } from "@mui/icons-material";
|
import { Psychology as PsychologyIcon, Person as PersonIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, Lightbulb as TipsIcon, Article as ArticleIcon } from "@mui/icons-material";
|
||||||
import { PodcastAnalysis, PodcastEstimate } from "./types";
|
import { PodcastAnalysis, PodcastEstimate } from "./types";
|
||||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||||
import { aiApiClient } from "../../api/client";
|
import { aiApiClient } from "../../api/client";
|
||||||
import { InputsTab, AudienceTab, OutlineTab, TitlesTab, HookTab, TakeawaysTab, GuestTab, CTATab } from "./AnalysisPanel/tabs";
|
import { InputsTab, AudienceTab, OutlineTab, EpisodeDetailsTab, TakeawaysTab, GuestTab } from "./AnalysisPanel/tabs";
|
||||||
|
|
||||||
interface AnalysisPanelProps {
|
interface AnalysisPanelProps {
|
||||||
analysis: PodcastAnalysis | null;
|
analysis: PodcastAnalysis | null;
|
||||||
@@ -19,7 +19,7 @@ interface AnalysisPanelProps {
|
|||||||
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = 'inputs' | 'audience' | 'content' | 'outline' | 'titles' | 'hook' | 'takeaways' | 'cta' | 'guest';
|
type TabId = 'inputs' | 'audience' | 'outline' | 'details' | 'takeaways' | 'guest';
|
||||||
|
|
||||||
interface TabConfig {
|
interface TabConfig {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -76,33 +76,42 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
|
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
|
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
|
||||||
{ id: 'audience', label: 'Audience', icon: <GroupsIcon /> },
|
{ id: 'audience', label: 'Audience & Keywords', icon: <GroupsIcon /> },
|
||||||
{ id: 'content', label: 'Content', icon: <ListAltIcon /> },
|
|
||||||
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
|
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
|
||||||
{ id: 'titles', label: 'Titles', icon: <EditNoteIcon /> },
|
{ id: 'details', label: 'Titles, Hook & CTA', icon: <ArticleIcon /> },
|
||||||
{ id: 'hook', label: 'Hook', icon: <AutoAwesomeIcon /> },
|
|
||||||
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
|
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
|
||||||
{ id: 'guest', label: 'Guest', icon: <PersonIcon /> },
|
{ id: 'guest', label: 'Guest Talking Points', icon: <PersonIcon /> },
|
||||||
{ id: 'cta', label: 'CTA', icon: <VoiceIcon /> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabButtonStyles = (isActive: boolean) => ({
|
const tabButtonStyles = (isActive: boolean) => ({
|
||||||
background: isActive
|
background: isActive
|
||||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||||
: "transparent",
|
: "#f8fafc",
|
||||||
color: isActive ? "#fff" : "#64748b",
|
color: isActive ? "#fff" : "#475569",
|
||||||
border: isActive ? "none" : "1px solid rgba(0,0,0,0.1)",
|
border: isActive
|
||||||
borderRadius: 2,
|
? "none"
|
||||||
px: 2,
|
: "1px solid #e2e8f0",
|
||||||
py: 1,
|
borderRadius: 2.5,
|
||||||
fontSize: "0.75rem",
|
px: 2.5,
|
||||||
|
py: 1.25,
|
||||||
|
fontSize: "0.8rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: "none" as const,
|
textTransform: "none" as const,
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.25s ease",
|
||||||
|
boxShadow: isActive
|
||||||
|
? "0 4px 12px rgba(102, 126, 234, 0.3)"
|
||||||
|
: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
background: isActive
|
background: isActive
|
||||||
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
|
||||||
: "rgba(102,126,234,0.08)",
|
: "#e2e8f0",
|
||||||
|
transform: isActive ? "translateY(-1px)" : "none",
|
||||||
|
boxShadow: isActive
|
||||||
|
? "0 6px 16px rgba(102, 126, 234, 0.35)"
|
||||||
|
: "0 2px 4px rgba(0, 0, 0, 0.08)",
|
||||||
|
},
|
||||||
|
"&:active": {
|
||||||
|
transform: "translateY(0)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +124,6 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (editedAnalysis && onUpdateAnalysis) {
|
if (editedAnalysis && onUpdateAnalysis) {
|
||||||
console.log('[AnalysisPanel] Saving updated analysis:', editedAnalysis);
|
|
||||||
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
|
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -254,8 +262,6 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
if (!analysis) return null;
|
if (!analysis) return null;
|
||||||
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
|
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
|
||||||
|
|
||||||
console.log('[AnalysisPanel] Rendering:', { isEditing, hasEditedAnalysis: !!editedAnalysis });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassyCard
|
<GlassyCard
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
@@ -419,8 +425,8 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'titles' && (
|
{activeTab === 'details' && (
|
||||||
<TitlesTab
|
<EpisodeDetailsTab
|
||||||
analysis={currentAnalysis}
|
analysis={currentAnalysis}
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
handleRemoveTitle={handleRemoveTitle}
|
handleRemoveTitle={handleRemoveTitle}
|
||||||
@@ -428,10 +434,6 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'hook' && (
|
|
||||||
<HookTab analysis={currentAnalysis} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'takeaways' && (
|
{activeTab === 'takeaways' && (
|
||||||
<TakeawaysTab analysis={currentAnalysis} />
|
<TakeawaysTab analysis={currentAnalysis} />
|
||||||
)}
|
)}
|
||||||
@@ -439,10 +441,6 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
|||||||
{activeTab === 'guest' && (
|
{activeTab === 'guest' && (
|
||||||
<GuestTab analysis={currentAnalysis} />
|
<GuestTab analysis={currentAnalysis} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'cta' && (
|
|
||||||
<CTATab analysis={currentAnalysis} />
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</GlassyCard>
|
</GlassyCard>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Stack, Box, Typography, Chip, TextField, IconButton, Paper, Divider } from "@mui/material";
|
||||||
|
import { EditNote as EditNoteIcon, Add as AddIcon, AutoAwesome as AutoAwesomeIcon, CallToAction as CTAIcon, Edit as EditIcon } from "@mui/icons-material";
|
||||||
|
import { PodcastAnalysis } from "../../types";
|
||||||
|
import { AnalysisTabContent } from "../AnalysisTabNav";
|
||||||
|
|
||||||
|
interface EpisodeDetailsTabProps {
|
||||||
|
analysis: PodcastAnalysis;
|
||||||
|
isEditing?: boolean;
|
||||||
|
handleRemoveTitle?: (title: string) => void;
|
||||||
|
handleAddTitle?: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
'& .MuiInputBase-input': { color: '#111827 !important', fontWeight: 500 },
|
||||||
|
'& .MuiInputLabel-root': { color: '#4b5563 !important' },
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: '#ffffff !important',
|
||||||
|
'& fieldset': { borderColor: '#d1d5db !important' },
|
||||||
|
'&:hover fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
'&.Mui-focused fieldset': { borderColor: '#4f46e5 !important' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodeDetailsTab: React.FC<EpisodeDetailsTabProps> = ({
|
||||||
|
analysis,
|
||||||
|
isEditing,
|
||||||
|
handleRemoveTitle,
|
||||||
|
handleAddTitle
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AnalysisTabContent title="Episode Details" icon={<EditIcon />}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
{/* Titles Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<EditNoteIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Episode Titles
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" flexWrap="wrap" useFlexGap sx={{ gap: 1 }}>
|
||||||
|
{analysis.titleSuggestions?.map((title: string, idx: number) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={title}
|
||||||
|
size="small"
|
||||||
|
onDelete={isEditing ? () => handleRemoveTitle?.(title) : undefined}
|
||||||
|
sx={{
|
||||||
|
color: "#0f172a",
|
||||||
|
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "normal",
|
||||||
|
height: "auto",
|
||||||
|
py: 0.5,
|
||||||
|
"&:hover": { background: "#e2e8f0" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{isEditing && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Add title suggestion..."
|
||||||
|
sx={{ ...inputStyles, mt: 2 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTitle?.((e.target as HTMLInputElement).value);
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton size="small" onClick={(e) => {
|
||||||
|
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||||
|
handleAddTitle?.(input.value);
|
||||||
|
input.value = '';
|
||||||
|
}}>
|
||||||
|
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
|
||||||
|
{/* Hook Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<AutoAwesomeIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Episode Hook
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{analysis.episode_hook ? (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#f0f9ff", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#0369a1", fontStyle: "italic", lineHeight: 1.6 }}>
|
||||||
|
"{analysis.episode_hook}"
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
|
||||||
|
No episode hook generated yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
|
||||||
|
A 15-30 second opening hook to grab listener attention.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
|
||||||
|
<CTAIcon sx={{ color: "#4f46e5", fontSize: 20 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#1e293b", fontWeight: 700 }}>
|
||||||
|
Listener CTA
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
{analysis.listener_cta ? (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, bgcolor: "#fff7ed", border: "1px solid rgba(249,115,22,0.2)", borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#c2410c", fontWeight: 500, lineHeight: 1.6 }}>
|
||||||
|
{analysis.listener_cta}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: "#94a3b8", fontStyle: "italic" }}>
|
||||||
|
No listener call-to-action generated yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: "#94a3b8", mt: 1, display: "block" }}>
|
||||||
|
A call-to-action for listeners after the episode.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</AnalysisTabContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,3 +7,4 @@ export { TitlesTab } from "./TitlesTab";
|
|||||||
export { OutlineTab } from "./OutlineTab";
|
export { OutlineTab } from "./OutlineTab";
|
||||||
export { AudienceTab } from "./AudienceTab";
|
export { AudienceTab } from "./AudienceTab";
|
||||||
export { InputsTab } from "./InputsTab";
|
export { InputsTab } from "./InputsTab";
|
||||||
|
export { EpisodeDetailsTab } from "./EpisodeDetailsTab";
|
||||||
|
|||||||
@@ -501,19 +501,28 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
const { podcastApi } = await import("../../services/podcastApi");
|
const { podcastApi } = await import("../../services/podcastApi");
|
||||||
const result = await podcastApi.makeAvatarPresentable(avatarUrl);
|
const result = await podcastApi.makeAvatarPresentable(avatarUrl);
|
||||||
|
|
||||||
// Fetch the transformed image as blob to display
|
if (result.avatar_url) {
|
||||||
const { aiApiClient } = await import("../../api/client");
|
// Fetch the transformed image as blob to display
|
||||||
const response = await aiApiClient.get(result.avatar_url, { responseType: 'blob' });
|
const { aiApiClient } = await import("../../api/client");
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
const response = await aiApiClient.get(result.avatar_url, { responseType: 'blob' });
|
||||||
setAvatarPreview(blobUrl);
|
const blobUrl = URL.createObjectURL(response.data);
|
||||||
setAvatarUrl(result.avatar_url);
|
|
||||||
|
// Revoke old blob URL if exists
|
||||||
|
if (avatarPreviewBlobUrl && avatarPreviewBlobUrl.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(avatarPreviewBlobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarPreviewBlobUrl(blobUrl);
|
||||||
|
setAvatarPreview(result.avatar_url);
|
||||||
|
setAvatarUrl(result.avatar_url);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to make avatar presentable:', error);
|
console.error('Failed to make avatar presentable:', error);
|
||||||
// Could show error message to user
|
// Could show error message to user
|
||||||
} finally {
|
} finally {
|
||||||
setMakingPresentable(false);
|
setMakingPresentable(false);
|
||||||
}
|
}
|
||||||
}, [avatarUrl, makingPresentable]);
|
}, [avatarUrl, makingPresentable, avatarPreviewBlobUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Stack, Box, Typography, Tabs, Tab, CircularProgress, Button, IconButton, Tooltip, alpha } from "@mui/material";
|
import { Stack, Box, Typography, Tabs, Tab, CircularProgress, Button, IconButton, Tooltip, alpha, useTheme, useMediaQuery } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Person as PersonIcon,
|
Person as PersonIcon,
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
@@ -56,6 +56,15 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
cameraSelfieOpen,
|
cameraSelfieOpen,
|
||||||
setCameraSelfieOpen,
|
setCameraSelfieOpen,
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const isTablet = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
// Shorter tab labels for mobile
|
||||||
|
const tabLabels = isMobile
|
||||||
|
? ["Brand", "Library", "Selfie", "Upload"]
|
||||||
|
: ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -130,23 +139,28 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
value={avatarTab}
|
value={avatarTab}
|
||||||
onChange={setAvatarTab}
|
onChange={setAvatarTab}
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
scrollButtons="auto"
|
scrollButtons={isMobile ? "auto" : false}
|
||||||
|
allowScrollButtonsMobile={isMobile}
|
||||||
sx={{
|
sx={{
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 2, sm: 3 },
|
||||||
minHeight: { xs: 40, sm: 48 },
|
minHeight: { xs: 36, sm: 48 },
|
||||||
|
"& .MuiTabs-scrollButtons": {
|
||||||
|
color: "#64748b",
|
||||||
|
"&.Mui-disabled": { opacity: 0.3 },
|
||||||
|
},
|
||||||
"& .MuiTabs-indicator": {
|
"& .MuiTabs-indicator": {
|
||||||
display: "none",
|
display: "none",
|
||||||
},
|
},
|
||||||
"& .MuiTabs-flexContainer": {
|
"& .MuiTabs-flexContainer": {
|
||||||
gap: { xs: 1, sm: 1.5 },
|
gap: { xs: 0.5, sm: 1.5 },
|
||||||
},
|
},
|
||||||
"& .MuiTab-root": {
|
"& .MuiTab-root": {
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
minHeight: { xs: 36, sm: 44 },
|
minHeight: { xs: 32, sm: 44 },
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: { xs: "0.75rem", sm: "0.875rem" },
|
fontSize: { xs: "0.7rem", sm: "0.875rem" },
|
||||||
borderRadius: { xs: "8px", sm: "12px" },
|
borderRadius: { xs: "6px", sm: "12px" },
|
||||||
px: { xs: 1.5, sm: 2.5 },
|
px: { xs: 1, sm: 2.5 },
|
||||||
minWidth: { xs: "auto", sm: 0 },
|
minWidth: { xs: "auto", sm: 0 },
|
||||||
color: "#64748b",
|
color: "#64748b",
|
||||||
border: "1.5px solid #e2e8f0",
|
border: "1.5px solid #e2e8f0",
|
||||||
@@ -155,7 +169,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: "#cbd5e1",
|
borderColor: "#cbd5e1",
|
||||||
backgroundColor: "#f8fafc",
|
backgroundColor: "#f8fafc",
|
||||||
transform: "translateY(-1px)",
|
transform: { xs: "none", sm: "translateY(-1px)" },
|
||||||
},
|
},
|
||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
@@ -166,10 +180,9 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab label="Use Brand Avatar" />
|
{tabLabels.map((label, index) => (
|
||||||
<Tab label="Asset Library" />
|
<Tab key={index} label={label} />
|
||||||
<Tab label="Take Selfie" />
|
))}
|
||||||
<Tab label="Upload Your Photo" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{avatarTab === 0 && (
|
{avatarTab === 0 && (
|
||||||
@@ -340,7 +353,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
<Box sx={{ position: "relative", display: "inline-block", width: "100%", maxWidth: { xs: 220, sm: 280 } }}>
|
<Box sx={{ position: "relative", display: "inline-block", width: "100%", maxWidth: { xs: 220, sm: 280 } }}>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
|
src={avatarPreviewBlobUrl || avatarPreview || ""}
|
||||||
alt="Selfie preview"
|
alt="Selfie preview"
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -475,7 +488,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
|||||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
|
src={avatarPreviewBlobUrl || avatarPreview || ""}
|
||||||
alt="Avatar preview"
|
alt="Avatar preview"
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 120, sm: 160 },
|
width: { xs: 120, sm: 160 },
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Box,
|
Box,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
@@ -27,6 +31,15 @@ import {
|
|||||||
ListAlt as ListAltIcon,
|
ListAlt as ListAltIcon,
|
||||||
Psychology as PsychologyIcon,
|
Psychology as PsychologyIcon,
|
||||||
RecordVoiceOver as RecordVoiceOverIcon,
|
RecordVoiceOver as RecordVoiceOverIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
VideoCameraFront as VideoIcon,
|
||||||
|
TrendingUp as TrendingUpIcon,
|
||||||
|
Headphones as HeadphonesIcon,
|
||||||
|
Article as ArticleIcon,
|
||||||
|
Campaign as CampaignIcon,
|
||||||
|
Groups as GroupsIcon,
|
||||||
|
School as SchoolIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||||
|
|
||||||
@@ -35,12 +48,9 @@ interface CreateActionsProps {
|
|||||||
submit: () => void;
|
submit: () => void;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
announcement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Constants & Data
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const ANALYSIS_FEATURES = [
|
const ANALYSIS_FEATURES = [
|
||||||
{ icon: <AnalyticsIcon />, text: "Target audience & content type analysis" },
|
{ icon: <AnalyticsIcon />, text: "Target audience & content type analysis" },
|
||||||
{ icon: <ListAltIcon />, text: "5 high-impact keywords for discoverability" },
|
{ icon: <ListAltIcon />, text: "5 high-impact keywords for discoverability" },
|
||||||
@@ -50,21 +60,85 @@ const ANALYSIS_FEATURES = [
|
|||||||
{ icon: <CheckCircleIcon />, text: "Episode hook, key takeaways & listener CTA" },
|
{ icon: <CheckCircleIcon />, text: "Episode hook, key takeaways & listener CTA" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANALYSIS_PROGRESS_STEPS = [
|
// Sequential educational messages - displayed one after another
|
||||||
"Analyzing target audience & content type",
|
const EDUCATIONAL_MESSAGES = [
|
||||||
"Generating keywords & title suggestions",
|
{ title: "Understanding Your Topic", message: "AI is analyzing your topic to identify the core theme, target audience, and content type..." },
|
||||||
"Creating episode outlines",
|
{ title: "Audience Persona", message: "Building a detailed audience persona including demographics, interests, and pain points..." },
|
||||||
"Generating research queries",
|
{ title: "Keyword Research", message: "Discovering high-impact keywords that will make your podcast discoverable in search..." },
|
||||||
"Creating hook, takeaways & CTA",
|
{ title: "Title Optimization", message: "Generating catchy, SEO-friendly title suggestions that capture attention..." },
|
||||||
|
{ title: "Content Structure", message: "Creating a compelling narrative arc with hooks, segments, and call-to-actions..." },
|
||||||
|
{ title: "Research Queries", message: "Preparing intelligent research questions that will gather facts and insights..." },
|
||||||
|
{ title: "Episode Outline", message: "Drafting a structured outline with timestamps and key talking points..." },
|
||||||
|
{ title: "Quality Check", message: "Validating all generated content for accuracy and engagement potential..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PODCAST_CREATION_JOURNEY = [
|
||||||
|
{
|
||||||
|
phase: "Analysis",
|
||||||
|
icon: <AnalyticsIcon />,
|
||||||
|
color: "#a78bfa",
|
||||||
|
description: "AI learns your topic inside-out",
|
||||||
|
details: [
|
||||||
|
"Identifies target audience demographics and interests",
|
||||||
|
"Extracts key themes and angles to explore",
|
||||||
|
"Generates research queries for deep diving",
|
||||||
|
],
|
||||||
|
benefit: "Ensures your content resonates with the right people"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Research",
|
||||||
|
icon: <SearchIcon />,
|
||||||
|
color: "#60a5fa",
|
||||||
|
description: "Deep dive into facts and insights",
|
||||||
|
details: [
|
||||||
|
"Gathers statistics, quotes, and case studies",
|
||||||
|
"Finds trending topics and recent developments",
|
||||||
|
"Validates claims with credible sources",
|
||||||
|
],
|
||||||
|
benefit: "Adds credibility and depth to your episode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Script",
|
||||||
|
icon: <EditIcon />,
|
||||||
|
color: "#34d399",
|
||||||
|
description: "AI crafts your episode narrative",
|
||||||
|
details: [
|
||||||
|
"Creates scene-by-scene breakdowns",
|
||||||
|
"Writes natural dialogue and transitions",
|
||||||
|
"Optimizes pacing for engagement",
|
||||||
|
],
|
||||||
|
benefit: "Professional script without hours of writing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: "Render",
|
||||||
|
icon: <VideoIcon />,
|
||||||
|
color: "#f472b6",
|
||||||
|
description: "Bring it all together visually",
|
||||||
|
details: [
|
||||||
|
"Combines voice clone with avatar",
|
||||||
|
"Adds visual effects and transitions",
|
||||||
|
"Exports studio-quality video",
|
||||||
|
],
|
||||||
|
benefit: "Ready-to-publish podcast video"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARKETING_INSIGHTS = [
|
||||||
|
{ icon: <TrendingUpIcon />, title: "Brand Recognition", text: "Consistent podcasting builds brand authority and trust" },
|
||||||
|
{ icon: <GroupsIcon />, title: "Audience Growth", text: "AI-optimized content attracts and retains listeners" },
|
||||||
|
{ icon: <SchoolIcon />, title: "Thought Leadership", text: "Research-backed content positions you as an expert" },
|
||||||
|
{ icon: <CampaignIcon />, title: "Marketing Funnel", text: "Podcasts drive traffic to your products and services" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USE_CASES = [
|
||||||
|
{ icon: <HeadphonesIcon />, title: "Regular Episodes", text: "Weekly/bi-weekly podcast episodes" },
|
||||||
|
{ icon: <ArticleIcon />, title: "Content Repurposing", text: "Turn blogs and videos into podcasts" },
|
||||||
|
{ icon: <CampaignIcon />, title: "Marketing Campaigns", text: "Launch promotions with audio content" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const INFO_BANNER_TEXT =
|
const INFO_BANNER_TEXT =
|
||||||
"Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically.";
|
"Podcast avatar Image is required. Brand avatar is default. You can choose from asset library or upload your picture. If not, AI Avatar will be generated automatically.";
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Styles
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
dialog: {
|
dialog: {
|
||||||
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||||
@@ -77,23 +151,16 @@ const styles = {
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||||
},
|
},
|
||||||
progressDot: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
bgcolor: "#a78bfa",
|
|
||||||
},
|
|
||||||
dialogContent: {
|
dialogContent: {
|
||||||
color: "rgba(255,255,255,0.8)",
|
color: "rgba(255,255,255,0.8)",
|
||||||
minHeight: 200,
|
minHeight: 200,
|
||||||
py: 3,
|
py: 2,
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
|
maxHeight: { xs: "80vh", sm: "70vh" },
|
||||||
|
overflowY: "auto",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Sub-Components
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({
|
const InfoBanner: React.FC<{ showInfo: boolean; setShowInfo: (v: boolean) => void }> = ({
|
||||||
showInfo,
|
showInfo,
|
||||||
setShowInfo,
|
setShowInfo,
|
||||||
@@ -121,47 +188,176 @@ const ShowTipsLink: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AnalysisProgressView: React.FC = () => (
|
const AnalysisProgressView: React.FC<{ currentMessage?: string; progressIndex: number }> = ({ currentMessage, progressIndex }) => {
|
||||||
<Stack spacing={3} alignItems="center" sx={styles.dialogContent} justifyContent="center">
|
const theme = useTheme();
|
||||||
<Box sx={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
<CircularProgress size={80} thickness={3} sx={{ color: "#a78bfa" }} />
|
const clampedIndex = Math.min(progressIndex, EDUCATIONAL_MESSAGES.length - 1);
|
||||||
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
|
||||||
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: 32 }} />
|
return (
|
||||||
</Box>
|
<Stack spacing={2} sx={styles.dialogContent}>
|
||||||
</Box>
|
{/* Current Status */}
|
||||||
|
<Box sx={{ textAlign: "center" }}>
|
||||||
|
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#a78bfa" }} />
|
||||||
|
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: isMobile ? 20 : 24 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ color: "#fff", textAlign: "center" }}>
|
<Typography variant="subtitle1" sx={{ color: "#a78bfa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
|
||||||
Analyzing Your Podcast Idea
|
{EDUCATIONAL_MESSAGES[clampedIndex].title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
|
||||||
|
{EDUCATIONAL_MESSAGES[clampedIndex].message}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<LinearProgress
|
{currentMessage && currentMessage !== EDUCATIONAL_MESSAGES[clampedIndex].message && (
|
||||||
sx={{
|
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
|
||||||
width: "100%",
|
{currentMessage}
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: "rgba(255,255,255,0.1)",
|
|
||||||
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 4 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack spacing={1} sx={{ width: "100%" }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", textAlign: "center" }}>
|
|
||||||
This may take a few moments...
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={0.5} alignItems="flex-start" sx={{ pl: 2 }}>
|
|
||||||
{ANALYSIS_PROGRESS_STEPS.map((step, idx) => (
|
|
||||||
<Typography key={idx} variant="caption" sx={{ color: "rgba(255,255,255,0.5)", display: "flex", alignItems: "center", gap: 0.5 }}>
|
|
||||||
<Box sx={styles.progressDot} /> {step}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
)}
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
const WhatYoullGetView: React.FC = () => (
|
<LinearProgress
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "rgba(255,255,255,0.1)",
|
||||||
|
mt: 2,
|
||||||
|
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 2 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
|
||||||
|
Step {clampedIndex + 1} of {EDUCATIONAL_MESSAGES.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Sequential Progress Steps */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Analysis Progress
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
{EDUCATIONAL_MESSAGES.map((msg, idx) => {
|
||||||
|
const isCompleted = idx < clampedIndex;
|
||||||
|
const isCurrent = idx === clampedIndex;
|
||||||
|
return (
|
||||||
|
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<CircularProgress size={10} sx={{ color: "#fff" }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.6)",
|
||||||
|
fontWeight: isCurrent ? 600 : 400,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
textDecoration: isCompleted ? "line-through" : "none",
|
||||||
|
}}>
|
||||||
|
{msg.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Journey Overview - Responsive */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Your Podcast Journey
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
|
||||||
|
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||||
|
<Box sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: `${phase.color}20`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
|
||||||
|
{phase.phase}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
|
||||||
|
{phase.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
|
||||||
|
✓ {phase.benefit}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||||
|
|
||||||
|
{/* Marketing Insights */}
|
||||||
|
<Box sx={{ width: "100%" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||||
|
Marketing Benefits
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||||
|
{MARKETING_INSIGHTS.map((insight, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
icon={React.cloneElement(insight.icon, { sx: { fontSize: 14, color: "#a78bfa" } })}
|
||||||
|
label={insight.title}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "rgba(167, 139, 250, 0.1)",
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
height: 24,
|
||||||
|
mb: 0.5,
|
||||||
|
"& .MuiChip-label": { fontSize: "0.65rem", px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WhatYoullGetView: React.FC<{ isMobile?: boolean }> = ({ isMobile }) => (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)" }}>
|
<Typography variant="body2" sx={{ mb: 2, color: "rgba(255,255,255,0.7)", fontSize: isMobile ? "0.85rem" : "0.9rem" }}>
|
||||||
Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you:
|
Click "Start Analysis" to begin AI-powered podcast planning. Here's what we'll generate for you:
|
||||||
</Typography>
|
</Typography>
|
||||||
<List>
|
<List>
|
||||||
@@ -170,7 +366,7 @@ const WhatYoullGetView: React.FC = () => (
|
|||||||
<ListItemIcon sx={{ minWidth: 36, color: "#a78bfa" }}>{feature.icon}</ListItemIcon>
|
<ListItemIcon sx={{ minWidth: 36, color: "#a78bfa" }}>{feature.icon}</ListItemIcon>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={feature.text}
|
primary={feature.text}
|
||||||
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: "0.9rem" } }}
|
primaryTypographyProps={{ sx: { color: "rgba(255,255,255,0.9)", fontSize: isMobile ? "0.8rem" : "0.9rem" } }}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
@@ -178,26 +374,34 @@ const WhatYoullGetView: React.FC = () => (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting, announcement }) => {
|
||||||
// Main Component
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, canSubmit, isSubmitting }) => {
|
|
||||||
const [showInfo, setShowInfo] = useState(true);
|
const [showInfo, setShowInfo] = useState(true);
|
||||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||||
|
const [progressIndex, setProgressIndex] = useState(0);
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setShowInfo(false), 8000);
|
const timer = setTimeout(() => setShowInfo(false), 8000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close modal when analysis completes
|
// Sequential progress - increment every few seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSubmitting && analysisStarted) {
|
if (!isSubmitting || !analysisStarted) {
|
||||||
setShowAnalysisModal(false);
|
setProgressIndex(0);
|
||||||
setAnalysisStarted(false);
|
return;
|
||||||
}
|
}
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgressIndex((prev) => {
|
||||||
|
if (prev < EDUCATIONAL_MESSAGES.length - 1) {
|
||||||
|
return prev + 1;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [isSubmitting, analysisStarted]);
|
}, [isSubmitting, analysisStarted]);
|
||||||
|
|
||||||
const handleSubmitClick = () => {
|
const handleSubmitClick = () => {
|
||||||
@@ -206,11 +410,16 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
|
|
||||||
const handleStartAnalysis = () => {
|
const handleStartAnalysis = () => {
|
||||||
setAnalysisStarted(true);
|
setAnalysisStarted(true);
|
||||||
|
setProgressIndex(0);
|
||||||
submit();
|
submit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
const showProgressInModal = showAnalysisModal && (analysisStarted || isSubmitting);
|
||||||
|
|
||||||
|
const buttonText = canSubmit
|
||||||
|
? "Continue to Research Topic"
|
||||||
|
: "Provide Podcast Inputs to Continue";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<InfoBanner showInfo={showInfo} setShowInfo={setShowInfo} />
|
<InfoBanner showInfo={showInfo} setShowInfo={setShowInfo} />
|
||||||
@@ -225,9 +434,9 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
disabled={!canSubmit || isSubmitting}
|
disabled={!canSubmit || isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
startIcon={<AutoAwesomeIcon />}
|
startIcon={<AutoAwesomeIcon />}
|
||||||
tooltip={!canSubmit ? "Complete all steps: 1) Enter topic/URL, 2) Configure duration & speakers, 3) Add avatar, 4) Select voice" : "We'll start AI analysis after this click"}
|
tooltip={!canSubmit ? "Complete all 4 steps: 1) Enter topic/URL, 2) Configure duration & speakers, 3) Add avatar, 4) Select voice" : "We'll start AI analysis after this click"}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
{isSubmitting ? "Analyzing..." : buttonText}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -236,13 +445,14 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
onClose={() => !isSubmitting && setShowAnalysisModal(false)}
|
onClose={() => !isSubmitting && setShowAnalysisModal(false)}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{ sx: styles.dialog }}
|
fullScreen={isMobile}
|
||||||
|
PaperProps={{ sx: { ...styles.dialog, ...(isMobile ? { borderRadius: 0 } : {}) } }}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1 }}>
|
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1, fontSize: isMobile ? "1rem" : "1.25rem" }}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<CircularProgress size={24} sx={{ color: "#a78bfa" }} />
|
<CircularProgress size={20} sx={{ color: "#a78bfa" }} />
|
||||||
Analyzing Your Podcast Idea
|
Creating Your Podcast
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
@@ -252,11 +462,15 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent sx={styles.dialogContent}>
|
<DialogContent sx={{ ...styles.dialogContent, ...(isMobile ? { px: 2, py: 2 } : {}) }}>
|
||||||
{showProgressInModal ? <AnalysisProgressView /> : <WhatYoullGetView />}
|
{showProgressInModal ? (
|
||||||
|
<AnalysisProgressView currentMessage={announcement} progressIndex={progressIndex} />
|
||||||
|
) : (
|
||||||
|
<WhatYoullGetView isMobile={isMobile} />
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{ px: 3, pb: 3 }}>
|
<DialogActions sx={{ px: 3, pb: 3, ...(isMobile ? { px: 2, pb: 2 } : {}) }}>
|
||||||
{showProgressInModal ? null : (
|
{showProgressInModal ? null : (
|
||||||
<>
|
<>
|
||||||
<SecondaryButton onClick={() => setShowAnalysisModal(false)}>Cancel</SecondaryButton>
|
<SecondaryButton onClick={() => setShowAnalysisModal(false)}>Cancel</SecondaryButton>
|
||||||
@@ -269,4 +483,4 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -26,7 +26,7 @@ const PodcastDashboard: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const skip = shouldSkipOnboarding();
|
const skip = shouldSkipOnboarding();
|
||||||
console.log('PodcastDashboard entry: shouldSkipOnboarding =', skip);
|
// Skip onboarding in podcast-only mode
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('PodcastDashboard entry: gating log error', e);
|
console.warn('PodcastDashboard entry: gating log error', e);
|
||||||
}
|
}
|
||||||
@@ -224,6 +224,7 @@ const PodcastDashboard: React.FC = () => {
|
|||||||
onCreate={workflow.handleCreate}
|
onCreate={workflow.handleCreate}
|
||||||
defaultKnobs={DEFAULT_KNOBS}
|
defaultKnobs={DEFAULT_KNOBS}
|
||||||
isSubmitting={workflow.isAnalyzing}
|
isSubmitting={workflow.isAnalyzing}
|
||||||
|
announcement={workflow.announcement}
|
||||||
/>
|
/>
|
||||||
<RecentEpisodesPreview onSelectEpisode={() => {}} />
|
<RecentEpisodesPreview onSelectEpisode={() => {}} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import { podcastApi } from "../../../services/podcastApi";
|
import { podcastApi } from "../../../services/podcastApi";
|
||||||
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
||||||
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||||
@@ -344,8 +344,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
}
|
}
|
||||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
|
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
|
||||||
|
|
||||||
// Add a ref to track if we're currently generating to prevent double calls
|
// Add a ref to track if we're currently generating to prevent double calls
|
||||||
const isGeneratingRef = React.useRef(false);
|
const isGeneratingRef = useRef(false);
|
||||||
|
|
||||||
const handleGenerateScript = useCallback(async () => {
|
const handleGenerateScript = useCallback(async () => {
|
||||||
// Guard against double calls
|
// Guard against double calls
|
||||||
@@ -360,8 +360,9 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as generating immediately
|
// Mark as generating immediately (both ref and state)
|
||||||
isGeneratingRef.current = true;
|
isGeneratingRef.current = true;
|
||||||
|
setIsGeneratingScript(true);
|
||||||
|
|
||||||
setPreflightOperationName("Script Generation");
|
setPreflightOperationName("Script Generation");
|
||||||
const preflightResult = await preflightCheck.check({
|
const preflightResult = await preflightCheck.check({
|
||||||
@@ -373,13 +374,13 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
|||||||
|
|
||||||
if (!preflightResult.can_proceed) {
|
if (!preflightResult.can_proceed) {
|
||||||
isGeneratingRef.current = false; // Reset on preflight failure
|
isGeneratingRef.current = false; // Reset on preflight failure
|
||||||
|
setIsGeneratingScript(false); // Reset loading state on preflight failure
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setScriptData(null);
|
setScriptData(null);
|
||||||
setShowRenderQueue(false);
|
setShowRenderQueue(false);
|
||||||
setShowScriptEditor(true);
|
setShowScriptEditor(true);
|
||||||
setIsGeneratingScript(true);
|
|
||||||
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
|
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Cleanup function - stops all tracks and clears video
|
// Cleanup function - stops all tracks and clears video
|
||||||
const cleanupCamera = useCallback(() => {
|
const cleanupCamera = useCallback(() => {
|
||||||
console.log('[RobustCamera] Cleaning up camera');
|
|
||||||
|
|
||||||
// Reset attachment tracking
|
// Reset attachment tracking
|
||||||
streamAttachedRef.current = false;
|
streamAttachedRef.current = false;
|
||||||
|
|
||||||
@@ -62,7 +60,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
// Stop all tracks in the stream
|
// Stop all tracks in the stream
|
||||||
if (stream) {
|
if (stream) {
|
||||||
stream.getTracks().forEach(track => {
|
stream.getTracks().forEach(track => {
|
||||||
console.log('[RobustCamera] Stopping track:', track.kind, track.label);
|
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,28 +77,23 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Early exit conditions
|
// Early exit conditions
|
||||||
if (!video || !stream) {
|
if (!video || !stream) {
|
||||||
console.log('[RobustCamera] Cannot attach - video:', !!video, 'stream:', !!stream);
|
|
||||||
streamAttachedRef.current = false;
|
streamAttachedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already attached to this stream
|
// Skip if already attached to this stream
|
||||||
if (video.srcObject === stream && streamAttachedRef.current) {
|
if (video.srcObject === stream && streamAttachedRef.current) {
|
||||||
console.log('[RobustCamera] Stream already attached to video');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[RobustCamera] Attaching stream to video element');
|
|
||||||
streamAttachedRef.current = true;
|
streamAttachedRef.current = true;
|
||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
console.log('[RobustCamera] Video metadata loaded, playing...');
|
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
video.play()
|
video.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('[RobustCamera] Video playing successfully');
|
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setCameraReady(true);
|
setCameraReady(true);
|
||||||
}
|
}
|
||||||
@@ -130,7 +122,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Cleanup function - only remove listeners, don't detach stream
|
// Cleanup function - only remove listeners, don't detach stream
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[RobustCamera] Cleaning up video event listeners');
|
|
||||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
video.removeEventListener('error', handleError);
|
video.removeEventListener('error', handleError);
|
||||||
};
|
};
|
||||||
@@ -141,22 +132,18 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
console.log('[RobustCamera] Dialog opened');
|
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
|
|
||||||
const initCamera = async () => {
|
const initCamera = async () => {
|
||||||
// Prevent double initialization
|
// Prevent double initialization
|
||||||
if (isInitializingRef.current) {
|
if (isInitializingRef.current) {
|
||||||
console.log('[RobustCamera] Already initializing, skipping');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
console.log('[RobustCamera] Cancelled before initialization');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[RobustCamera] Starting camera initialization');
|
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -164,7 +151,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Clean up any existing stream first
|
// Clean up any existing stream first
|
||||||
if (stream) {
|
if (stream) {
|
||||||
console.log('[RobustCamera] Cleaning up existing stream first');
|
|
||||||
stream.getTracks().forEach(track => track.stop());
|
stream.getTracks().forEach(track => track.stop());
|
||||||
setStream(null);
|
setStream(null);
|
||||||
}
|
}
|
||||||
@@ -179,18 +165,15 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
audio: false,
|
audio: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[RobustCamera] Requesting camera with constraints:', constraints);
|
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
|
||||||
// Check if cancelled or unmounted after await
|
// Check if cancelled or unmounted after await
|
||||||
if (isCancelled || !isMountedRef.current) {
|
if (isCancelled || !isMountedRef.current) {
|
||||||
console.log('[RobustCamera] Cancelled after stream obtained, stopping stream');
|
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length);
|
|
||||||
setStream(mediaStream);
|
setStream(mediaStream);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@@ -228,13 +211,11 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[RobustCamera] Cleanup from open effect');
|
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Dialog closed - cleanup
|
// Dialog closed - cleanup
|
||||||
console.log('[RobustCamera] Dialog closed, cleaning up');
|
|
||||||
cleanupCamera();
|
cleanupCamera();
|
||||||
}
|
}
|
||||||
}, [open]); // Only depend on open to prevent re-runs
|
}, [open]); // Only depend on open to prevent re-runs
|
||||||
@@ -244,8 +225,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
if (open && stream) {
|
if (open && stream) {
|
||||||
console.log('[RobustCamera] Facing mode changed to', facingMode, ', reinitializing');
|
|
||||||
|
|
||||||
const reinitCamera = async () => {
|
const reinitCamera = async () => {
|
||||||
cleanupCamera();
|
cleanupCamera();
|
||||||
|
|
||||||
@@ -304,7 +283,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[RobustCamera] Component unmounting');
|
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
cleanupCamera();
|
cleanupCamera();
|
||||||
};
|
};
|
||||||
@@ -313,7 +291,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
// Capture photo
|
// Capture photo
|
||||||
const capturePhoto = useCallback(() => {
|
const capturePhoto = useCallback(() => {
|
||||||
if (!videoElementRef.current || !canvasRef.current || !cameraReady) {
|
if (!videoElementRef.current || !canvasRef.current || !cameraReady) {
|
||||||
console.log('[RobustCamera] Cannot capture: not ready');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +301,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
canvas.width = video.videoWidth || 1280;
|
canvas.width = video.videoWidth || 1280;
|
||||||
canvas.height = video.videoHeight || 720;
|
canvas.height = video.videoHeight || 720;
|
||||||
|
|
||||||
console.log('[RobustCamera] Capturing photo:', canvas.width, 'x', canvas.height);
|
|
||||||
|
|
||||||
// Draw video frame to canvas
|
// Draw video frame to canvas
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
if (context) {
|
if (context) {
|
||||||
@@ -339,7 +314,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Convert to data URL
|
// Convert to data URL
|
||||||
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||||
console.log('[RobustCamera] Photo captured');
|
|
||||||
|
|
||||||
onCapture(imageDataUrl);
|
onCapture(imageDataUrl);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -348,7 +322,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
|
|||||||
|
|
||||||
// Flip camera
|
// Flip camera
|
||||||
const flipCamera = useCallback(() => {
|
const flipCamera = useCallback(() => {
|
||||||
console.log('[RobustCamera] Flipping camera');
|
|
||||||
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
|
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -8,36 +8,46 @@
|
|||||||
const PRIMARY_STORAGE_KEY = 'enabled_features';
|
const PRIMARY_STORAGE_KEY = 'enabled_features';
|
||||||
const PRIMARY_ENV_KEY = 'REACT_APP_ENABLED_FEATURES';
|
const PRIMARY_ENV_KEY = 'REACT_APP_ENABLED_FEATURES';
|
||||||
|
|
||||||
|
// Cache for enabled features to avoid repeated logging
|
||||||
|
let cachedFeatures: Set<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get enabled features from localStorage or environment.
|
* Get enabled features from localStorage or environment.
|
||||||
* Returns a Set of enabled feature names.
|
* Returns a Set of enabled feature names.
|
||||||
*/
|
*/
|
||||||
export function getEnabledFeatures(): Set<string> {
|
export function getEnabledFeatures(): Set<string> {
|
||||||
|
// Return cached value if available
|
||||||
|
if (cachedFeatures) {
|
||||||
|
return cachedFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
// Check localStorage first
|
// Check localStorage first
|
||||||
const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY);
|
const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY);
|
||||||
if (storageValue) {
|
if (storageValue) {
|
||||||
console.log('demoMode: Found in localStorage:', storageValue);
|
|
||||||
const features = storageValue.toLowerCase().split(',').map(f => f.trim());
|
const features = storageValue.toLowerCase().split(',').map(f => f.trim());
|
||||||
if (features.includes('all')) {
|
if (features.includes('all')) {
|
||||||
return new Set(['all']);
|
cachedFeatures = new Set(['all']);
|
||||||
|
return cachedFeatures;
|
||||||
}
|
}
|
||||||
return new Set(features.filter(f => f));
|
cachedFeatures = new Set(features.filter(f => f));
|
||||||
|
return cachedFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variable
|
// Check environment variable
|
||||||
const envValue = process.env[PRIMARY_ENV_KEY];
|
const envValue = process.env[PRIMARY_ENV_KEY];
|
||||||
console.log('demoMode: ENV value for', PRIMARY_ENV_KEY, ':', envValue);
|
|
||||||
if (envValue) {
|
if (envValue) {
|
||||||
const features = envValue.toLowerCase().split(',').map(f => f.trim());
|
const features = envValue.toLowerCase().split(',').map(f => f.trim());
|
||||||
if (features.includes('all')) {
|
if (features.includes('all')) {
|
||||||
return new Set(['all']);
|
cachedFeatures = new Set(['all']);
|
||||||
|
return cachedFeatures;
|
||||||
}
|
}
|
||||||
return new Set(features.filter(f => f));
|
cachedFeatures = new Set(features.filter(f => f));
|
||||||
|
return cachedFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: all features enabled
|
// Default: all features enabled
|
||||||
console.log('demoMode: No env var, returning default "all"');
|
cachedFeatures = new Set(['all']);
|
||||||
return new Set(['all']);
|
return cachedFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user