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:
ajaysi
2026-04-07 16:28:11 +05:30
parent 1a456b21b7
commit e59c77b221
17 changed files with 851 additions and 198 deletions

View File

@@ -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")

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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):

View File

@@ -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(

View File

@@ -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}")

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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

View File

@@ -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 },

View File

@@ -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>
); );
}; };

View File

@@ -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={() => {}} />
</> </>

View File

@@ -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 {

View File

@@ -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');
}, []); }, []);

View File

@@ -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;
} }
/** /**