"feat:enhance-podcast-topic-ai"

This commit is contained in:
ajaysi
2026-03-11 19:09:27 +05:30
parent e472861967
commit 01881bb405
51 changed files with 3627 additions and 218 deletions

View File

@@ -0,0 +1,405 @@
import React, { useState, useRef, useCallback } from 'react';
import {
Box,
Button,
IconButton,
Typography,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tooltip,
alpha,
} from '@mui/material';
import {
Camera as CameraIcon,
FlipCameraAndroid as FlipCameraIcon,
Close as CloseIcon,
PhotoCamera as PhotoCameraIcon,
VideocamOff as VideocamOffIcon,
} from '@mui/icons-material';
interface CameraSelfieProps {
onCapture: (imageDataUrl: string) => void;
onClose: () => void;
open: boolean;
}
export const CameraSelfie: React.FC<CameraSelfieProps> = ({ onCapture, onClose, open }) => {
const [stream, setStream] = useState<MediaStream | null>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cameraAvailable, setCameraAvailable] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const startCamera = useCallback(async () => {
if (loading) {
return; // Prevent multiple simultaneous camera requests
}
setLoading(true);
setError(null);
try {
// Stop existing stream
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
setStream(mediaStream);
// Function to attach stream to video element
const attachStreamToVideo = () => {
if (videoRef.current) {
// Clear any existing stream
if (videoRef.current.srcObject) {
const oldStream = videoRef.current.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
}
// Attach new stream
videoRef.current.srcObject = mediaStream;
// Wait for video to be ready
videoRef.current.onloadedmetadata = () => {
setCameraAvailable(true);
setLoading(false);
// Try to play the video
videoRef.current?.play().catch(err => {
console.error('Video play error:', err);
});
};
// Handle video errors
videoRef.current.onerror = (err) => {
console.error('Video error:', err);
setError('Failed to display camera feed.');
setLoading(false);
};
return true; // Successfully attached
}
return false; // Video ref not available
};
// Try to attach immediately
if (!attachStreamToVideo()) {
// Retry every 100ms for up to 2 seconds
let retryCount = 0;
const retryInterval = setInterval(() => {
retryCount++;
if (attachStreamToVideo() || retryCount >= 20) {
clearInterval(retryInterval);
if (retryCount >= 20) {
setCameraAvailable(true);
setLoading(false);
}
}
}, 100);
}
} catch (err) {
console.error('Camera access error:', err);
setCameraAvailable(false);
setLoading(false); // Set loading to false in error case
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Camera access denied. Please allow camera permissions to take a selfie.');
} else if (err.name === 'NotFoundError') {
setError('No camera found on this device.');
} else if (err.name === 'NotReadableError') {
setError('Camera is already in use by another application.');
} else {
setError('Failed to access camera. Please try again.');
}
}
}
}, [facingMode, stream, loading]);
const stopCamera = useCallback(() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
}, [stream]);
const capturePhoto = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw the current video frame to canvas
const context = canvas.getContext('2d');
if (context) {
// Flip horizontally for selfie (mirror effect)
context.translate(canvas.width, 0);
context.scale(-1, 1);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert to data URL
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
onCapture(imageDataUrl);
}
}, [onCapture]);
const flipCamera = useCallback(() => {
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
}, []);
// Start camera when dialog opens
React.useEffect(() => {
if (open) {
// Small delay to ensure video element is mounted
const timer = setTimeout(() => {
startCamera();
}, 100);
return () => {
clearTimeout(timer);
stopCamera();
};
}
}, [open, startCamera, stopCamera]); // Add back dependencies with proper useCallback
// Restart camera when facing mode changes
React.useEffect(() => {
if (open && stream) {
// Stop current stream before starting new one
stopCamera();
// Small delay to ensure proper cleanup
setTimeout(() => {
startCamera();
}, 100);
}
}, [facingMode, open, stream, startCamera, stopCamera]); // Add back dependencies
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
overflow: 'hidden',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bgcolor: 'primary.main',
color: '#ffffff',
}}
>
Take a Selfie
<IconButton onClick={onClose} sx={{ color: '#ffffff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 0, minHeight: 400 }}>
{error && (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
)}
{loading && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
flexDirection: 'column',
gap: 2,
}}
>
<CircularProgress size={48} />
<Typography variant="body2" color="text.secondary">
Accessing camera...
</Typography>
</Box>
)}
{!loading && !error && cameraAvailable && (
<Box sx={{ position: 'relative', width: '100%', bgcolor: '#000000', minHeight: 400 }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: '100%',
minHeight: 400,
objectFit: 'cover',
display: 'block',
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none',
}}
/>
{/* Camera controls overlay */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
p: 2,
background: 'linear-gradient(to top, rgba(0,0,0,0.7), transparent)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 2,
}}
>
<Tooltip title="Flip Camera">
<IconButton
onClick={flipCamera}
sx={{
bgcolor: alpha('#ffffff', 0.2),
color: '#ffffff',
'&:hover': {
bgcolor: alpha('#ffffff', 0.3),
},
}}
>
<FlipCameraIcon />
</IconButton>
</Tooltip>
<Tooltip title="Take Photo">
<IconButton
onClick={capturePhoto}
sx={{
bgcolor: '#ffffff',
color: '#000000',
width: 56,
height: 56,
'&:hover': {
bgcolor: alpha('#ffffff', 0.9),
},
}}
>
<PhotoCameraIcon sx={{ fontSize: 32 }} />
</IconButton>
</Tooltip>
<Tooltip title="Close">
<IconButton
onClick={onClose}
sx={{
bgcolor: alpha('#ffffff', 0.2),
color: '#ffffff',
'&:hover': {
bgcolor: alpha('#ffffff', 0.3),
},
}}
>
<VideocamOffIcon />
</IconButton>
</Tooltip>
</Box>
{/* Face guide overlay */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 200,
height: 250,
border: '2px dashed rgba(255,255,255,0.3)',
borderRadius: 2,
pointerEvents: 'none',
}}
>
<Typography
variant="caption"
sx={{
position: 'absolute',
top: -25,
left: '50%',
transform: 'translateX(-50%)',
color: '#ffffff',
bgcolor: 'rgba(0,0,0,0.5)',
px: 1,
py: 0.5,
borderRadius: 1,
fontSize: '0.75rem',
}}
>
Position face here
</Typography>
</Box>
</Box>
)}
{!cameraAvailable && !error && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
flexDirection: 'column',
gap: 2,
}}
>
<CameraIcon sx={{ fontSize: 64, color: 'text.secondary' }} />
<Typography variant="h6" color="text.secondary">
Camera Not Available
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center">
Your device doesn't have a camera or it's not accessible.
Please use the file upload option instead.
</Typography>
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button onClick={onClose} variant="outlined">
Cancel
</Button>
{cameraAvailable && (
<Button onClick={capturePhoto} variant="contained" startIcon={<PhotoCameraIcon />}>
Take Photo
</Button>
)}
</DialogActions>
{/* Hidden canvas for image capture */}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Dialog>
);
};

View File

@@ -3,7 +3,7 @@ import { Stack, Paper, Box } from "@mui/material";
import { CreateProjectPayload, Knobs } from "./types";
import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl } from "../../utils/fetchMediaBlobUrl";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
import { getLatestBrandAvatar } from "../../api/brandAssets";
// Imported Components
@@ -12,6 +12,13 @@ import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput";
import { PodcastConfiguration } from "./CreateStep/PodcastConfiguration";
import { AvatarSelector } from "./CreateStep/AvatarSelector";
import { CreateActions } from "./CreateStep/CreateActions";
import { EnhancedTopicChoicesModal } from "./EnhancedTopicChoicesModal";
const ENHANCE_TOPIC_PROGRESS_MESSAGES = [
"Analyzing your topic idea...",
"Enhancing clarity and hook...",
"Aligning language for podcast listeners...",
];
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
@@ -33,11 +40,20 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [avatarPreviewBlobUrl, setAvatarPreviewBlobUrl] = useState<string | null>(null);
const [makingPresentable, setMakingPresentable] = useState(false);
const [enhancingTopic, setEnhancingTopic] = useState(false);
const [enhanceTopicProgressIndex, setEnhanceTopicProgressIndex] = useState(0);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [avatarTab, setAvatarTab] = useState(0);
const [loadingBrandAvatar, setLoadingBrandAvatar] = useState(false);
const [brandAvatarFromDb, setBrandAvatarFromDb] = useState<string | null>(null);
const [cameraSelfieOpen, setCameraSelfieOpen] = useState(false);
// Enhanced topic choices state
const [enhancedChoices, setEnhancedChoices] = useState<string[]>([]);
const [enhancedRationales, setEnhancedRationales] = useState<string[]>([]);
const [choicesModalOpen, setChoicesModalOpen] = useState(false);
const [editedChoices, setEditedChoices] = useState<string[]>([]);
// Rotate placeholder every 3 seconds
useEffect(() => {
@@ -140,6 +156,11 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
let isMounted = true;
const loadBrandBlob = async () => {
try {
// Clear cache for this URL to ensure fresh data
if (brandAvatarFromDb) {
clearMediaCache(brandAvatarFromDb);
}
const blobUrl = await fetchMediaBlobUrl(brandAvatarFromDb);
if (isMounted) setBrandAvatarBlobUrl(blobUrl);
} catch (err) {
@@ -172,29 +193,57 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
};
const isUrl = useMemo(() => detectUrl(topicInput), [topicInput]);
const enhanceTopicMessage = enhancingTopic ? ENHANCE_TOPIC_PROGRESS_MESSAGES[enhanceTopicProgressIndex] : undefined;
useEffect(() => {
if (!enhancingTopic) {
setEnhanceTopicProgressIndex(0);
return;
}
const interval = setInterval(() => {
setEnhanceTopicProgressIndex((prev) => (prev + 1) % ENHANCE_TOPIC_PROGRESS_MESSAGES.length);
}, 1200);
return () => clearInterval(interval);
}, [enhancingTopic]);
// Handle AI Details button click
const handleAIDetailsClick = async () => {
if (!topicInput.trim() || makingPresentable) return;
if (!topicInput.trim() || enhancingTopic) return;
try {
setMakingPresentable(true);
setEnhancingTopic(true);
// We pass the current Bible context if we have it (unlikely here as it's generated in analysis)
// But the backend will generate it from onboarding data if missing
const result = await podcastApi.enhanceIdea({
idea: topicInput,
});
if (result.enhanced_idea) {
setTopicInput(result.enhanced_idea);
if (result.enhanced_ideas && result.enhanced_ideas.length === 3) {
setEnhancedChoices(result.enhanced_ideas);
setEnhancedRationales(result.rationales || []);
setEditedChoices(result.enhanced_ideas); // Initialize editable versions
setChoicesModalOpen(true);
}
} catch (error) {
console.error("Failed to enhance idea with AI:", error);
} finally {
setMakingPresentable(false);
setEnhancingTopic(false);
}
};
// Handle enhanced topic choice selection
const handleChoiceSelection = (selectedIndex: number, editedChoice: string) => {
const selectedTopic = editedChoice;
setTopicInput(selectedTopic);
setChoicesModalOpen(false);
// Reset choices state
setEnhancedChoices([]);
setEnhancedRationales([]);
setEditedChoices([]);
};
// Show AI details button when user starts typing (and it's not a URL)
useEffect(() => {
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
@@ -203,7 +252,6 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
// Calculate estimated cost
const estimatedCost = useMemo(() => {
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
const scenes = Math.ceil((duration * 60) / (knobs.scene_length_target || 45));
const secs = duration * 60;
const ttsCost = (chars / 1000) * 0.05;
@@ -282,6 +330,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setAvatarPreview(null);
setAvatarUrl(null);
setMakingPresentable(false);
setEnhancingTopic(false);
setEnhanceTopicProgressIndex(0);
setKnobs({ ...defaultKnobs });
setPlaceholderIndex(0);
};
@@ -325,6 +375,34 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
};
const handleCameraSelfie = async (imageDataUrl: string) => {
try {
// Convert dataURL to File object
const response = await fetch(imageDataUrl);
const blob = await response.blob();
const file = new File([blob], 'selfie.jpg', { type: 'image/jpeg' });
// Set the file and preview
setAvatarFile(file);
setAvatarPreview(imageDataUrl);
// Upload image immediately to get URL (for "Make Presentable" feature)
try {
const { podcastApi } = await import("../../services/podcastApi");
const uploadResult = await podcastApi.uploadAvatar(file);
setAvatarUrl(uploadResult.avatar_url);
} catch (error) {
console.error('Avatar upload failed:', error);
// Continue with local preview - upload will happen on submit
}
// Close camera dialog
setCameraSelfieOpen(false);
} catch (error) {
console.error('Failed to process selfie:', error);
}
};
const handleRemoveAvatar = () => {
setAvatarFile(null);
setAvatarPreview(null);
@@ -442,7 +520,8 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
showAIDetailsButton={showAIDetailsButton}
onAIDetailsClick={handleAIDetailsClick}
placeholderIndex={placeholderIndex}
loading={makingPresentable}
loading={enhancingTopic}
loadingMessage={enhanceTopicMessage}
/>
</Box>
@@ -466,12 +545,15 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
handleUseBrandAvatar={handleUseBrandAvatar}
handleAvatarSelectFromLibrary={handleAvatarSelectFromLibrary}
handleAvatarChange={handleAvatarChange}
handleCameraSelfie={handleCameraSelfie}
handleRemoveAvatar={handleRemoveAvatar}
handleMakePresentable={handleMakePresentable}
makingPresentable={makingPresentable}
avatarPreviewBlobUrl={avatarPreviewBlobUrl}
brandAvatarFromDb={brandAvatarFromDb}
brandAvatarBlobUrl={brandAvatarBlobUrl}
cameraSelfieOpen={cameraSelfieOpen}
setCameraSelfieOpen={setCameraSelfieOpen}
/>
<CreateActions
@@ -480,6 +562,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
canSubmit={canSubmit}
isSubmitting={isSubmitting}
/>
{/* Enhanced Topic Choices Modal */}
<EnhancedTopicChoicesModal
open={choicesModalOpen}
onClose={() => setChoicesModalOpen(false)}
enhancedChoices={enhancedChoices}
enhancedRationales={enhancedRationales}
onSelectChoice={handleChoiceSelection}
loading={enhancingTopic}
/>
</Stack>
</Paper>
);

View File

@@ -9,8 +9,10 @@ import {
Delete as DeleteIcon,
AutoAwesome as AutoAwesomeIcon,
CloudUpload as CloudUploadIcon,
PhotoCamera as PhotoCameraIcon,
} from "@mui/icons-material";
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
import { CameraSelfie } from "../CameraSelfie";
import { SecondaryButton } from "../ui";
interface AvatarSelectorProps {
@@ -23,12 +25,15 @@ interface AvatarSelectorProps {
handleUseBrandAvatar: () => void;
handleAvatarSelectFromLibrary: (url: string) => void;
handleAvatarChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleCameraSelfie: (imageDataUrl: string) => void;
handleRemoveAvatar: () => void;
handleMakePresentable: () => void;
makingPresentable: boolean;
avatarPreviewBlobUrl: string | null;
brandAvatarFromDb?: string | null;
brandAvatarBlobUrl?: string | null;
cameraSelfieOpen: boolean;
setCameraSelfieOpen: (open: boolean) => void;
}
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
@@ -41,21 +46,16 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
handleUseBrandAvatar,
handleAvatarSelectFromLibrary,
handleAvatarChange,
handleCameraSelfie,
handleRemoveAvatar,
handleMakePresentable,
makingPresentable,
avatarPreviewBlobUrl,
brandAvatarFromDb,
brandAvatarBlobUrl,
cameraSelfieOpen,
setCameraSelfieOpen,
}) => {
const isAuthenticatedUrl = React.useCallback((url: string | null): boolean => {
if (!url) return false;
return url.includes('/api/podcast/') ||
url.includes('/api/youtube/') ||
url.includes('/api/story/') ||
(url.startsWith('/') && !url.startsWith('//'));
}, []);
return (
<Box
sx={{
@@ -92,9 +92,10 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
Avatar Options:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.<br/><br/>
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/><br/>
<strong>Asset Library:</strong> Choose from your previously uploaded images.
<strong>Asset Library:</strong> Choose from your previously uploaded images.<br/><br/>
<strong>Take a Selfie:</strong> Use your camera to capture a photo instantly for your podcast presenter.<br/><br/>
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.
</Typography>
</Box>
}
@@ -149,6 +150,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
>
<Tab label="Use Brand Avatar" />
<Tab label="Asset Library" />
<Tab label="Take Selfie" />
<Tab label="Upload Your Photo" />
</Tabs>
@@ -311,6 +313,154 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
)}
{avatarTab === 2 && (
<Stack spacing={2}>
<Box>
{avatarFile && avatarPreview ? (
<Stack spacing={2} alignItems="center" sx={{ bgcolor: "#f8fafc", borderRadius: 2, p: 2 }}>
<Box sx={{ position: "relative", display: "inline-block" }}>
<Box
component="img"
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
alt="Selfie preview"
sx={{
width: 160,
height: 160,
objectFit: "cover",
borderRadius: 2.5,
border: "2px solid #e2e8f0",
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
}}
/>
<IconButton
size="small"
onClick={handleRemoveAvatar}
sx={{
position: "absolute",
top: -8,
right: -8,
bgcolor: "white",
border: "1.5px solid #e2e8f0",
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
"&:hover": {
bgcolor: "#f8fafc",
borderColor: "#dc2626",
color: "#dc2626",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{avatarUrl && (
<Tooltip
title="Transform your selfie into a professional podcast presenter."
arrow
placement="top"
>
<Box>
<Button
onClick={handleMakePresentable}
disabled={makingPresentable}
variant="contained"
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : <CircularProgress size={14} thickness={5} sx={{ color: "rgba(255,255,255,0.92)" }} />}
sx={{
width: "100%",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 600,
borderRadius: 2.5,
color: "#f8fbff",
px: 1.8,
border: "1px solid rgba(148, 211, 255, 0.6)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
boxShadow: "0 8px 18px rgba(37, 99, 235, 0.28), inset 0 1px 0 rgba(255,255,255,0.22)",
"&:hover": {
background: "linear-gradient(120deg, #38bdf8 0%, #2563eb 50%, #1e40af 100%)",
boxShadow: "0 12px 24px rgba(29, 78, 216, 0.35), inset 0 1px 0 rgba(255,255,255,0.26)",
transform: "translateY(-1px)",
},
"&.Mui-disabled": {
color: "#e2e8f0",
borderColor: "rgba(186, 230, 253, 0.7)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
opacity: 0.78,
},
}}
>
{makingPresentable ? "Transforming..." : "Make Presentable"}
</Button>
</Box>
</Tooltip>
)}
</Stack>
) : (
<Box
component="button"
onClick={() => setCameraSelfieOpen(true)}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
minHeight: 200,
border: "2px dashed #cbd5e1",
borderRadius: 2.5,
bgcolor: "#f8fafc",
cursor: "pointer",
transition: "all 0.2s",
"&:hover": {
borderColor: "#667eea",
bgcolor: "#f1f5f9",
borderWidth: "2.5px",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
},
}}
>
<PhotoCameraIcon sx={{ color: "#94a3b8", fontSize: 36, mb: 1.5 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600, mb: 0.5 }}>
Take a Selfie
</Typography>
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
Use your camera to capture a photo instantly
</Typography>
</Box>
)}
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f8fafc", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<PhotoCameraIcon fontSize="small" sx={{ color: "#64748b" }} />
Take a Selfie
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
Capture a photo using your device camera and use <strong>"Make Presentable"</strong> to enhance it into a professional presenter using AI.
</Typography>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f0f4ff", 0.5),
border: "1px solid rgba(99, 102, 241, 0.15)",
}}
>
<Typography variant="caption" sx={{ color: "#6366f1", fontSize: "0.8125rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 0.5 }}>
<InfoIcon fontSize="inherit" />
Camera access required for selfie capture
</Typography>
</Box>
</Stack>
)}
{avatarTab === 3 && (
<Stack spacing={2}>
<Box>
{avatarFile && avatarPreview ? (
@@ -442,6 +592,13 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
)}
</Box>
</Stack>
{/* Camera Selfie Dialog */}
<CameraSelfie
open={cameraSelfieOpen}
onClose={() => setCameraSelfieOpen(false)}
onCapture={handleCameraSelfie}
/>
</Box>
);
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Box, Typography, TextField, Tooltip, Button, alpha } from "@mui/material";
import { Box, Typography, TextField, Tooltip, Button, CircularProgress, alpha } from "@mui/material";
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
export const TOPIC_PLACEHOLDERS = [
@@ -19,6 +19,7 @@ interface TopicUrlInputProps {
onAIDetailsClick?: () => void;
placeholderIndex: number;
loading?: boolean;
loadingMessage?: string;
}
export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
@@ -29,6 +30,7 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
onAIDetailsClick,
placeholderIndex,
loading = false,
loadingMessage,
}) => {
return (
<Box
@@ -110,31 +112,51 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
/>
</Tooltip>
{/* Add details with AI button - appears when user types (and not a URL) */}
{/* Enhance topic with AI button - appears when user types (and not a URL) */}
{showAIDetailsButton && !isUrl && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1, flexDirection: "column", alignItems: "flex-end", gap: 0.6 }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoAwesomeIcon />}
variant="contained"
startIcon={
loading ? (
<CircularProgress size={14} thickness={5} sx={{ color: "rgba(255,255,255,0.92)" }} />
) : (
<AutoAwesomeIcon />
)
}
onClick={onAIDetailsClick}
disabled={loading}
sx={{
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 600,
borderColor: "#667eea",
borderWidth: 1.5,
color: "#667eea",
borderRadius: 2,
borderRadius: 2.5,
color: "#f8fbff",
px: 1.8,
border: "1px solid rgba(148, 211, 255, 0.6)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
boxShadow: "0 8px 18px rgba(37, 99, 235, 0.28), inset 0 1px 0 rgba(255,255,255,0.22)",
"&:hover": {
borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08),
background: "linear-gradient(120deg, #38bdf8 0%, #2563eb 50%, #1e40af 100%)",
boxShadow: "0 12px 24px rgba(29, 78, 216, 0.35), inset 0 1px 0 rgba(255,255,255,0.26)",
transform: "translateY(-1px)",
},
"&.Mui-disabled": {
color: "#e2e8f0",
borderColor: "rgba(186, 230, 253, 0.7)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
opacity: 0.78,
},
}}
>
{loading ? "Enhancing..." : "Add details with AI"}
{loading ? "Enhancing Topic With AI..." : "Enhance Topic With AI"}
</Button>
{loading && (
<Typography sx={{ fontSize: "0.75rem", color: "#1d4ed8", fontWeight: 600 }}>
{loadingMessage || "Analyzing your topic and improving clarity..."}
</Typography>
)}
</Box>
)}
</Box>

View File

@@ -0,0 +1,352 @@
import React, { useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
IconButton,
TextField,
Chip,
alpha,
CircularProgress,
} from "@mui/material";
import {
Close as CloseIcon,
AutoAwesome as AutoAwesomeIcon,
Edit as EditIcon,
CheckCircle as CheckCircleIcon,
Lightbulb as LightbulbIcon,
} from "@mui/icons-material";
interface EnhancedTopicChoicesModalProps {
open: boolean;
onClose: () => void;
enhancedChoices: string[];
enhancedRationales: string[];
onSelectChoice: (index: number, editedChoice: string) => void;
loading?: boolean;
}
const CHOICE_LABELS = [
{ label: "Professional", color: "#2563eb", description: "Expert-led approach" },
{ label: "Storytelling", color: "#7c3aed", description: "Human interest approach" },
{ label: "Trendy", color: "#dc2626", description: "Contemporary approach" },
];
export const EnhancedTopicChoicesModal: React.FC<EnhancedTopicChoicesModalProps> = ({
open,
onClose,
enhancedChoices,
enhancedRationales,
onSelectChoice,
loading = false,
}) => {
const [editedChoices, setEditedChoices] = useState<string[]>(() => {
const safeChoices = Array.isArray(enhancedChoices) ? enhancedChoices : [];
const result = [];
for (let i = 0; i < 3; i++) {
result[i] = (safeChoices[i] && typeof safeChoices[i] === 'string') ? safeChoices[i] : '';
}
return result;
});
const [editedIndices, setEditedIndices] = useState<Set<number>>(new Set());
React.useEffect(() => {
// Ensure editedChoices is always an array of length 3 with proper fallbacks
const safeChoices = Array.isArray(enhancedChoices) ? enhancedChoices : [];
const initializedChoices = [];
// Always create exactly 3 elements with safe values
for (let i = 0; i < 3; i++) {
initializedChoices[i] = (safeChoices[i] && typeof safeChoices[i] === 'string') ? safeChoices[i] : '';
}
setEditedChoices(initializedChoices);
setEditedIndices(new Set());
}, [enhancedChoices]);
const handleChoiceEdit = (index: number, newValue: string) => {
const updatedChoices = [...editedChoices];
updatedChoices[index] = newValue;
setEditedChoices(updatedChoices);
// Track which choices have been edited
const newEditedIndices = new Set(editedIndices);
if (newValue !== (enhancedChoices[index] || '')) {
newEditedIndices.add(index);
} else {
newEditedIndices.delete(index);
}
setEditedIndices(newEditedIndices);
};
const handleSelectChoice = (index: number) => {
onSelectChoice(index, editedChoices[index] || '');
};
const handleClose = () => {
setEditedIndices(new Set());
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)",
border: "1px solid rgba(148, 163, 184, 0.2)",
},
}}
>
<DialogTitle
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
p: 3,
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
color: "#ffffff",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<AutoAwesomeIcon />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Choose Your Enhanced Topic
</Typography>
</Box>
<IconButton onClick={handleClose} sx={{ color: "#ffffff" }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3 }}>
{loading ? (
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", py: 6, gap: 2 }}>
<CircularProgress size={48} thickness={5} sx={{ color: "#2563eb" }} />
<Typography variant="body1" color="text.secondary" sx={{ textAlign: "center" }}>
Generating enhanced topic options with AI...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: "center" }}>
Creating professional, storytelling, and contemporary angles for your topic
</Typography>
</Box>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{enhancedChoices.slice(0, 3).map((choice, index) => {
if (!choice) return null;
return (
<Box
key={index}
sx={{
p: 3,
borderRadius: 2.5,
border: `2px solid ${alpha(CHOICE_LABELS[index]?.color || '#667eea', 0.2)}`,
background: "#ffffff",
transition: "all 0.2s ease",
"&:hover": {
borderColor: CHOICE_LABELS[index]?.color || '#667eea',
boxShadow: `0 4px 12px ${alpha(CHOICE_LABELS[index]?.color || '#667eea', 0.15)}`,
transform: "translateY(-2px)",
},
}}
>
{/* Choice Header */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 2 }}>
<Chip
label={CHOICE_LABELS[index]?.label || `Choice ${index + 1}`}
size="small"
sx={{
background: CHOICE_LABELS[index]?.color || '#667eea',
color: "#ffffff",
fontWeight: 600,
fontSize: "0.75rem",
height: 28,
px: 1,
}}
/>
<Typography variant="body2" sx={{
color: "#64748b",
fontSize: "0.875rem",
fontWeight: 500,
letterSpacing: "0.025em"
}}>
{CHOICE_LABELS[index]?.description || 'Enhanced topic option'}
</Typography>
{editedIndices.has(index) && (
<EditIcon sx={{ fontSize: 16, color: "#64748b", ml: 'auto' }} />
)}
</Box>
{/* Editable Text Area */}
<TextField
multiline
rows={4}
fullWidth
value={editedChoices[index] || ''}
onChange={(e) => handleChoiceEdit(index, e.target.value)}
variant="outlined"
placeholder="Enhanced topic will appear here..."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.9),
borderRadius: 2,
border: "1px solid rgba(148, 163, 184, 0.23)",
boxShadow: "inset 0 1px 3px rgba(0, 0, 0, 0.05)",
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: "#ffffff",
borderColor: alpha(CHOICE_LABELS[index]?.color || '#667eea', 0.3),
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06), inset 0 1px 3px rgba(0, 0, 0, 0.05)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: CHOICE_LABELS[index]?.color || '#667eea',
boxShadow: `0 0 0 3px ${alpha(CHOICE_LABELS[index]?.color || '#667eea', 0.1)}, 0 4px 12px rgba(0, 0, 0, 0.08)`,
},
},
"& .MuiOutlinedInput-input": {
fontSize: "1rem",
lineHeight: 1.6,
letterSpacing: "0.01em",
padding: "16px 14px",
color: "#1e293b",
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
fontWeight: 400,
"&::placeholder": {
color: "#94a3b8",
fontStyle: "italic",
opacity: 0.8,
},
},
"& .MuiInputBase-multiline": {
padding: "0 !important",
},
}}
/>
{/* Rationale */}
{enhancedRationales[index] && (
<Box sx={{
mt: 2.5,
p: 2,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)",
border: "1px solid rgba(99, 102, 241, 0.1)",
}}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: "#4338ca",
fontSize: "0.875rem",
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 0.75,
}}
>
<LightbulbIcon sx={{ fontSize: 18, color: "#6366f1" }} />
Why this works:
</Typography>
<Typography
variant="body2"
sx={{
lineHeight: 1.6,
color: "#475569",
fontSize: "0.875rem",
letterSpacing: "0.005em",
}}
>
{enhancedRationales[index] || 'Enhanced topic option'}
</Typography>
</Box>
)}
{/* Action Button */}
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
<Button
onClick={() => handleSelectChoice(index)}
variant="contained"
size="medium"
startIcon={<CheckCircleIcon />}
disabled={(() => {
try {
return !editedChoices[index] || !editedChoices[index].trim();
} catch (error) {
console.error('Error in disabled condition:', error, { index, editedChoices });
return true; // Disable button if there's an error
}
})()}
sx={{
textTransform: "none",
fontSize: "0.9375rem",
fontWeight: 600,
borderRadius: 2,
color: "#ffffff",
px: 3,
py: 1,
border: "1px solid rgba(148, 211, 255, 0.6)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
boxShadow: "0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 0 rgba(255,255,255,0.22)",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
background: "linear-gradient(120deg, #0284c7 0%, #1d4ed8 55%, #1e40af 100%)",
boxShadow: "0 6px 20px rgba(37, 99, 235, 0.4), inset 0 1px 0 rgba(255,255,255,0.3)",
transform: "translateY(-1px)",
},
"&:active": {
transform: "translateY(0)",
boxShadow: "0 2px 8px rgba(37, 99, 235, 0.3)",
},
"&:disabled": {
background: "#f1f5f9",
color: "#94a3b8",
borderColor: "rgba(148, 163, 184, 0.3)",
boxShadow: "none",
"&:hover": {
background: "#f1f5f9",
transform: "none",
},
},
}}
>
Choose This Topic
</Button>
</Box>
</Box>
);
})}
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: 3, borderTop: "1px solid rgba(148, 163, 184, 0.2)" }}>
<Button
onClick={handleClose}
variant="outlined"
sx={{
textTransform: "none",
fontWeight: 600,
borderRadius: 2,
borderColor: "rgba(148, 163, 184, 0.4)",
color: "#64748b",
"&:hover": {
borderColor: "#94a3b8",
backgroundColor: alpha("#64748b", 0.04),
},
}}
>
Cancel
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -5,7 +5,11 @@ import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCirc
import { billingService } from '../../services/billingService';
import { useAuth } from '@clerk/clerk-react';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
import { apiClient } from '../../api/client';
import {
apiClient,
isBackendCooldownActive,
logBackendCooldownSkipOnce,
} from '../../api/client';
interface Alert {
id: string;
@@ -102,6 +106,11 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
const fetchAlerts = async () => {
if (!userId || isPollingRef.current) return;
if (isBackendCooldownActive()) {
logBackendCooldownSkipOnce('AlertsBadge');
return;
}
try {
isPollingRef.current = true;
setLoading(true);
@@ -213,10 +222,10 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
fetchAlerts();
}, 1000);
// Poll every 60 seconds
// Poll every 5 minutes (300 seconds) instead of 1 minute to reduce API call frequency
intervalRef.current = setInterval(() => {
fetchAlerts();
}, 60000);
}, 300000);
return () => {
clearTimeout(timeoutId);

View File

@@ -4,7 +4,11 @@ import { useUser, useClerk } from '@clerk/clerk-react';
import { useSubscription } from '../../contexts/SubscriptionContext';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
import UsageDashboard from './UsageDashboard';
import { apiClient } from '../../api/client';
import {
apiClient,
isBackendCooldownActive,
logBackendCooldownSkipOnce,
} from '../../api/client';
interface UserBadgeProps {
colorMode?: 'light' | 'dark';
@@ -27,6 +31,11 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
// Fetch system status for status bulb
useEffect(() => {
const fetchSystemStatus = async () => {
if (isBackendCooldownActive()) {
logBackendCooldownSkipOnce('UserBadge');
return;
}
try {
const response = await apiClient.get('/api/content-planning/monitoring/lightweight-stats');
const result = response.data;