"feat:enhance-podcast-topic-ai"
This commit is contained in:
405
frontend/src/components/PodcastMaker/CameraSelfie.tsx
Normal file
405
frontend/src/components/PodcastMaker/CameraSelfie.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user