WIP: AI Podcast Maker and YouTube Creator Studio integration

This commit is contained in:
ajaysi
2025-12-10 09:37:55 +05:30
parent 31f078c763
commit 81590cf4db
75 changed files with 11879 additions and 1380 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Box,
Paper,
@@ -120,6 +121,12 @@ const getStatusChip = (status: string) => {
};
export const AssetLibrary: React.FC = () => {
const [searchParams] = useSearchParams();
// Initialize filters from URL params if present
const urlSourceModule = searchParams.get('source_module');
const urlAssetType = searchParams.get('asset_type');
const [searchQuery, setSearchQuery] = useState('');
const [idSearch, setIdSearch] = useState('');
const [modelSearch, setModelSearch] = useState('');
@@ -127,7 +134,13 @@ export const AssetLibrary: React.FC = () => {
const [debouncedSearch, setDebouncedSearch] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); // Default to list like reference
const [tabValue, setTabValue] = useState(0);
const [filterType, setFilterType] = useState('all');
const [filterType, setFilterType] = useState(() => {
// Set filter type from URL if present
if (urlAssetType) {
return urlAssetType === 'audio' ? 'audio' : urlAssetType === 'image' ? 'images' : urlAssetType === 'video' ? 'videos' : urlAssetType === 'text' ? 'text' : 'all';
}
return 'all';
});
const [statusFilter, setStatusFilter] = useState('all');
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
const [page, setPage] = useState(0);
@@ -156,6 +169,11 @@ export const AssetLibrary: React.FC = () => {
offset: page * pageSize,
};
// Apply source_module from URL if present
if (urlSourceModule) {
baseFilters.source_module = urlSourceModule as any;
}
// Combine all search terms
const searchTerms: string[] = [];
if (debouncedSearch) searchTerms.push(debouncedSearch);
@@ -179,7 +197,7 @@ export const AssetLibrary: React.FC = () => {
}
return baseFilters;
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize]);
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);

View File

@@ -38,6 +38,7 @@ import { motion, type Variants, type Easing } from 'framer-motion';
import { useTransformStudio } from '../../hooks/useTransformStudio';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { PreflightOperation } from '../../services/billingService';
const MotionPaper = motion(Paper);
const MotionCard = motion(Card);
@@ -146,6 +147,19 @@ export const TransformStudio: React.FC = () => {
return imageBase64 && audioBase64;
}, [imageBase64, audioBase64]);
// Define preflight operations for cost estimation
const imageToVideoOperation: PreflightOperation = useMemo(() => ({
provider: 'wavespeed',
model: 'alibaba/wan-2.5/image-to-video',
operation_type: 'image-to-video',
}), []);
const talkingAvatarOperation: PreflightOperation = useMemo(() => ({
provider: 'wavespeed',
model: 'wavespeed-ai/infinitetalk',
operation_type: 'talking-avatar',
}), []);
const handleEstimateCost = useCallback(async () => {
if (tabValue === 0) {
// Image-to-video
@@ -510,13 +524,13 @@ export const TransformStudio: React.FC = () => {
Estimate Cost
</Button>
<OperationButton
operation={imageToVideoOperation}
label="Generate Video"
onClick={handleGenerate}
disabled={!canGenerateImageToVideo || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Video
</OperationButton>
/>
</Stack>
</Stack>
</MotionCard>
@@ -583,7 +597,6 @@ export const TransformStudio: React.FC = () => {
startIcon={<Upload />}
fullWidth
sx={{ py: 2 }}
required
>
{audioBase64 ? 'Change Audio' : 'Upload Audio (Required)'}
</Button>
@@ -709,13 +722,13 @@ export const TransformStudio: React.FC = () => {
Estimate Cost
</Button>
<OperationButton
operation={talkingAvatarOperation}
label="Generate Avatar"
onClick={handleGenerate}
disabled={!canGenerateTalkingAvatar || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Avatar
</OperationButton>
/>
</Stack>
</Stack>
</MotionCard>

View File

@@ -269,9 +269,14 @@ const GenerateChip: React.FC<{
const IconComponent = chip.icon;
const navigate = useNavigate();
const handleClick = () => {
if (chip.label === 'Today' && onTodayClick) {
onTodayClick();
} else if (chip.label === 'Video') {
// Navigate to YouTube Creator
navigate('/youtube-creator');
}
};
@@ -446,6 +451,8 @@ const GeneratePillarChips: React.FC<{
index: number;
isHovered?: boolean;
}> = ({ index, isHovered = false }) => {
const navigate = useNavigate();
// Generate pillar Today tasks
const generateTodayTasks: TodayTask[] = [
{
@@ -461,7 +468,7 @@ const GeneratePillarChips: React.FC<{
icon: FacebookIcon,
color: '#1877F2',
enabled: true,
action: () => console.log('Navigate to Facebook writer')
action: () => navigate('/facebook-writer')
},
{
id: 'blog-post',
@@ -491,7 +498,22 @@ const GeneratePillarChips: React.FC<{
icon: LinkedInIcon,
color: '#0077B5',
enabled: true,
action: () => console.log('Navigate to LinkedIn writer')
action: () => navigate('/linkedin-writer')
},
{
id: 'youtube-video',
pillarId: 'generate',
title: 'Create YouTube Video with AI',
description: 'Generate AI-powered YouTube videos from your ideas',
status: 'pending' as const,
priority: 'high' as const,
estimatedTime: 25,
actionType: 'navigate' as const,
actionUrl: '/youtube-creator',
icon: VideoIcon,
color: '#E91E63',
enabled: true,
action: () => navigate('/youtube-creator')
}
];
@@ -546,6 +568,16 @@ const GeneratePillarChips: React.FC<{
delay={index * 5}
/>
{/* Video Chip - Always Visible (Primary Action) */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
style={{ marginTop: '8px' }}
>
<GenerateChip chip={generateChips.video} delay={index * 5 + 1} />
</motion.div>
{/* More Options Indicator */}
{!isHovered && (
<motion.div
@@ -591,13 +623,6 @@ const GeneratePillarChips: React.FC<{
>
<GenerateChip chip={generateChips.audio} delay={index * 5 + 3} />
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<GenerateChip chip={generateChips.video} delay={index * 5 + 4} />
</motion.div>
</Box>
</motion.div>
)}

View File

@@ -0,0 +1,157 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
onRegenerate?: () => void;
}
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegenerate }) => {
if (!analysis) return null;
return (
<GlassyCard
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }}
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="analysis-panel"
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography
variant="h6"
sx={{
color: "#0f172a",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PsychologyIcon />
AI Analysis
</Typography>
<Typography variant="body2" color="text.secondary">
Insights derived from AI analysis of your topic and content preferences
</Typography>
</Box>
<SecondaryButton onClick={onRegenerate} startIcon={<RefreshIcon />} tooltip="Regenerate analysis with different parameters">
Regenerate
</SecondaryButton>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Target Audience
</Typography>
<Typography variant="body2" sx={{ color: "#0f172a" }}>
{analysis.audience}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.topKeywords.map((k) => (
<Chip
key={k}
label={k}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(0,0,0,0.1)",
color: "#0f172a",
background: "#f8fafc",
}}
/>
))}
</Stack>
</Box>
</Stack>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}>
{analysis.suggestedOutlines.map((o) => (
<Paper
key={o.id}
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
wordBreak: "break-word",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5, color: "#0f172a", wordBreak: "break-word" }}>
{o.title}
</Typography>
<Typography variant="caption" sx={{ color: "#475569", display: "block", wordBreak: "break-word" }}>
{o.segments.join(" • ")}
</Typography>
</Paper>
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.titleSuggestions.map((t) => (
<Chip
key={t}
label={t}
size="small"
sx={{
cursor: "pointer",
color: "#0f172a",
background: "#f8fafc",
maxWidth: "100%",
whiteSpace: "normal",
height: "auto",
lineHeight: 1.3,
"& .MuiChip-label": {
whiteSpace: "normal",
wordBreak: "break-word",
textAlign: "left",
paddingTop: 0.25,
paddingBottom: 0.25,
},
"&:hover": {
background: alpha("#667eea", 0.15),
border: "1px solid rgba(102,126,234,0.35)",
},
}}
/>
))}
</Stack>
</Box>
</Stack>
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,333 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
} from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui";
import { useSubscription } from "../../contexts/SubscriptionContext";
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
open: boolean;
defaultKnobs: Knobs;
isSubmitting?: boolean;
}
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
const { subscription } = useSubscription();
const [idea, setIdea] = useState("");
const [url, setUrl] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(10);
const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
// Determine subscription tier restrictions
const tier = subscription?.tier || 'free';
const isFreeTier = tier === 'free';
const isBasicTier = tier === 'basic';
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
// Reset HD quality if user downgrades
useEffect(() => {
if (!canUseHD && knobs.bitrate === 'hd') {
setKnobs({ ...knobs, bitrate: 'standard' });
}
}, [canUseHD]);
// Reset multi-speaker if user downgrades
useEffect(() => {
if (!canUseMultiSpeaker && speakers > 1) {
setSpeakers(1);
}
}, [canUseMultiSpeaker]);
// Show AI details button when user starts typing
useEffect(() => {
setShowAIDetailsButton(idea.trim().length > 0);
}, [idea]);
const canSubmit = Boolean(idea || url);
const submit = () => {
if (!canSubmit || isSubmitting) return;
onCreate({
ideaOrUrl: idea || url,
speakers,
duration,
knobs,
budgetCap,
files: { voiceFile, avatarFile },
});
};
const reset = () => {
setIdea("");
setUrl("");
setSpeakers(1);
setDuration(10);
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setKnobs({ ...defaultKnobs });
};
return (
<Paper
elevation={0}
sx={{
borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)",
background: "#ffffff",
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Stack direction="row" spacing={2} alignItems="center">
<AutoAwesomeIcon sx={{ color: "#667eea" }} />
<Box>
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}>
Create New Podcast Episode
</Typography>
<Typography variant="body2" color="text.secondary">
Provide either a topic idea or a blog post URL. We start AI analysis only after you click Analyze & Continue.
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1}>
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" />
<Chip label={`Duration: ${duration} min`} size="small" color="default" />
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" />
</Stack>
</Stack>
<Alert severity="info" sx={{ background: "#eef2ff", border: "1px solid #e0e7ff" }}>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Tips for best results:
</Typography>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Provide one clear topic OR a single blog URL (we wont auto-run anything).<br />
Keep it conciseone sentence topic works best.<br />
We start analysis only after you confirm, so you stay in control.
</Typography>
</Alert>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
{/* Topic Idea Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Topic Idea
</Typography>
<Tooltip
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
arrow
placement="top"
>
<TextField
fullWidth
multiline
rows={5}
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#0f172a",
},
}}
value={idea}
onChange={(e) => {
setIdea(e.target.value);
// Clear URL when typing idea
if (e.target.value.trim().length > 0) {
setUrl("");
}
}}
size="small"
helperText="We will not start analysis until you click Analyze."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
"& .MuiOutlinedInput-input": {
fontSize: "0.95rem",
lineHeight: 1.5,
color: "#0f172a",
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
},
"& .MuiFormHelperText-root": {
color: "#475569",
},
}}
/>
</Tooltip>
{/* Add details with AI button - appears when user types */}
{showAIDetailsButton && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoAwesomeIcon />}
onClick={() => {
// TODO: Implement AI details functionality
console.log("Add details with AI clicked");
}}
sx={{
textTransform: "none",
fontSize: "0.875rem",
borderColor: "#667eea",
color: "#667eea",
"&:hover": {
borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
Add details with AI
</Button>
</Box>
)}
</Box>
{/* Center OR divider */}
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}>
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} />
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} />
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Stack>
{/* Blog URL Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Blog Post URL
</Typography>
<Tooltip
title="Paste a single article URL. Well fetch insights only after you click Analyze."
arrow
placement="top"
>
<TextField
fullWidth
label="Paste blog post URL"
placeholder="https://yourblog.com/article"
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
color: "#0f172a",
},
}}
value={url}
onChange={(e) => {
setUrl(e.target.value);
// Clear idea when entering URL
if (e.target.value.trim().length > 0) {
setIdea("");
setShowAIDetailsButton(false);
}
}}
size="small"
helperText="We wont trigger analysis until you confirm."
InputProps={{
endAdornment: (
<Tooltip title="One URL is enough—keep it focused to reduce retries." arrow>
<InfoIcon sx={{ color: "action.disabled", fontSize: 18, ml: 1 }} />
</Tooltip>
),
}}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
},
"& .MuiFormHelperText-root": {
color: "#475569",
},
}}
/>
</Tooltip>
</Box>
</Stack>
{/* Quick settings for duration and speakers */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Duration (minutes)"
type="number"
value={duration}
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))}
InputProps={{ inputProps: { min: 1, max: 60 } }}
size="small"
helperText="Typical podcasts: 5-20 minutes"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))}
InputProps={{ inputProps: { min: 1, max: 4 } }}
size="small"
helperText="Supports single or panel style"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
</Stack>
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}>
You can provide either a topic idea or a blog post URL. We wont make any external AI calls until you click Analyze & Continue.
</Typography>
</Alert>
<Stack direction="row" justifyContent="flex-end" spacing={1}>
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
Reset
</SecondaryButton>
<PrimaryButton
onClick={submit}
disabled={!canSubmit || isSubmitting}
loading={isSubmitting}
startIcon={<AutoAwesomeIcon />}
tooltip={!canSubmit ? "Enter an idea or URL to continue" : "Well start AI analysis after this click"}
>
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
</PrimaryButton>
</Stack>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,92 @@
import React, { useMemo } from "react";
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha } from "@mui/material";
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon } from "@mui/icons-material";
import { Fact } from "./types";
import { GlassyCard, glassyCardSx } from "./ui";
interface FactCardProps {
fact: Fact;
}
export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
const hostname = useMemo(() => {
try {
return new URL(fact.url).hostname;
} catch {
return fact.url;
}
}, [fact.url]);
const handleCopy = () => {
navigator.clipboard.writeText(fact.quote);
};
return (
<GlassyCard
whileHover={{ y: -4 }}
sx={{
...glassyCardSx,
p: 2,
cursor: "pointer",
transition: "all 0.2s",
"&:hover": {
borderColor: "rgba(102,126,234,0.25)",
boxShadow: "0 12px 28px rgba(15,23,42,0.08)",
},
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
<Stack spacing={1.5}>
<Typography variant="body2" sx={{ lineHeight: 1.6, color: "#0f172a" }}>
{fact.quote}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center" flex={1}>
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.6)" }} />
<Typography
variant="caption"
component="a"
href={fact.url}
target="_blank"
rel="noreferrer"
sx={{
color: "#4f46e5",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{hostname || "source"}
</Typography>
</Stack>
<Tooltip title="Copy citation">
<IconButton size="small" onClick={handleCopy} sx={{ color: "rgba(15,23,42,0.65)" }}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={2}>
<Chip
label={`${(fact.confidence * 100).toFixed(0)}% confidence`}
size="small"
sx={{
height: 20,
fontSize: "0.65rem",
background: alpha("#22c55e", 0.15),
color: "#15803d",
border: "1px solid rgba(34,197,94,0.35)",
}}
/>
<Typography variant="caption" sx={{ color: "#475569" }}>
{fact.date}
</Typography>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState } from "react";
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
interface InlineAudioPlayerProps {
audioUrl: string;
title?: string;
}
export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl, title }) => {
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const audioRef = React.useRef<HTMLAudioElement>(null);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration);
const handleEnd = () => setPlaying(false);
audio.addEventListener("timeupdate", updateTime);
audio.addEventListener("loadedmetadata", updateDuration);
audio.addEventListener("ended", handleEnd);
return () => {
audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnd);
};
}, [audioUrl]);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
audio.play();
}
setPlaying(!playing);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const newTime = parseFloat(e.target.value);
audio.currentTime = newTime;
setCurrentTime(newTime);
};
return (
<Paper
sx={{
p: 2,
background: alpha("#1e293b", 0.6),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
}}
>
<Stack spacing={1.5}>
{title && (
<Typography variant="subtitle2" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<VolumeUpIcon fontSize="small" />
{title}
</Typography>
)}
<Stack direction="row" spacing={2} alignItems="center">
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large">
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
</IconButton>
<Stack flex={1}>
<input
type="range"
min={0}
max={duration || 0}
value={currentTime}
onChange={handleSeek}
style={{ width: "100%", cursor: "pointer" }}
/>
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{formatTime(currentTime)}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatTime(duration)}
</Typography>
</Stack>
</Stack>
<Tooltip title="Download audio">
<IconButton
onClick={() => {
const link = document.createElement("a");
link.href = audioUrl;
link.download = title || "podcast-audio.mp3";
link.click();
}}
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<DownloadIcon />
</IconButton>
</Tooltip>
</Stack>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
</Stack>
</Paper>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Button,
Box,
Alert,
Stack,
alpha,
} from '@mui/material';
import {
Block as BlockIcon,
Upgrade as UpgradeIcon,
Info as InfoIcon,
} from '@mui/icons-material';
import { PreflightCheckResponse } from '../../services/billingService';
import { useNavigate } from 'react-router-dom';
interface PreflightBlockDialogProps {
open: boolean;
onClose: () => void;
response: PreflightCheckResponse | null;
operationName?: string;
}
export const PreflightBlockDialog: React.FC<PreflightBlockDialogProps> = ({
open,
onClose,
response,
operationName = 'This operation',
}) => {
const navigate = useNavigate();
if (!response) return null;
const blockedOperation = response.operations.find((op) => !op.allowed);
const message = blockedOperation?.message || 'Operation blocked by subscription limits';
const limitInfo = blockedOperation?.limit_info;
const handleUpgrade = () => {
navigate('/pricing');
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: alpha('#0f172a', 0.95),
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" spacing={2} alignItems="center">
<BlockIcon sx={{ color: '#ef4444', fontSize: 32 }} />
<Box>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
Operation Blocked
</Typography>
<Typography variant="body2" color="text.secondary">
{operationName} cannot proceed
</Typography>
</Box>
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<Alert severity="error" sx={{ background: alpha('#ef4444', 0.1), border: '1px solid rgba(239,68,68,0.3)' }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{message}
</Typography>
</Alert>
{limitInfo && (
<Box sx={{ p: 2, background: alpha('#667eea', 0.1), borderRadius: 2, border: '1px solid rgba(102,126,234,0.3)' }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InfoIcon fontSize="small" />
Usage Limits
</Typography>
<Typography variant="body2" color="text.secondary">
Current: {limitInfo.current_usage.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Limit: {limitInfo.limit.toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Remaining: {limitInfo.remaining.toLocaleString()}
</Typography>
</Stack>
</Box>
)}
{response.estimated_cost > 0 && (
<Box sx={{ p: 2, background: alpha('#f59e0b', 0.1), borderRadius: 2, border: '1px solid rgba(245,158,11,0.3)' }}>
<Typography variant="body2" color="text.secondary">
Estimated Cost: ${response.estimated_cost.toFixed(4)}
</Typography>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<Button onClick={onClose} variant="outlined">
Close
</Button>
<Button
onClick={handleUpgrade}
variant="contained"
startIcon={<UpgradeIcon />}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5568d3 0%, #6a4190 100%)',
},
}}
>
Upgrade Plan
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,343 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Stack,
Typography,
Button,
Chip,
CircularProgress,
Alert,
alpha,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
} from "@mui/material";
import {
Mic as MicIcon,
PlayArrow as PlayArrowIcon,
Delete as DeleteIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
Refresh as RefreshIcon,
Search as SearchIcon,
} from "@mui/icons-material";
import { podcastApi } from "../../services/podcastApi";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
interface Project {
id: number;
project_id: string;
idea: string;
duration: number;
speakers: number;
current_step: string | null;
status: string;
is_favorite: boolean;
created_at: string;
updated_at: string;
}
interface ProjectListProps {
onSelectProject: (projectId: string) => void;
}
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) => {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const loadProjects = async () => {
try {
setLoading(true);
setError(null);
const response = await podcastApi.listProjects({
order_by: "updated_at",
limit: 50,
});
setProjects(response.projects);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProjects();
}, []);
const handleDelete = async () => {
if (!projectToDelete) return;
try {
await podcastApi.deleteProject(projectToDelete);
await loadProjects();
setDeleteDialogOpen(false);
setProjectToDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete project");
}
};
const handleToggleFavorite = async (projectId: string, currentFavorite: boolean) => {
try {
await podcastApi.toggleFavorite(projectId);
await loadProjects();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update favorite");
}
};
const getStepLabel = (step: string | null) => {
switch (step) {
case "analysis":
return "Analysis";
case "research":
return "Research";
case "script":
return "Script";
case "render":
return "Rendering";
default:
return "Draft";
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "completed":
return "success";
case "in_progress":
return "info";
case "draft":
return "default";
default:
return "default";
}
};
const filteredProjects = projects.filter((project) =>
project.idea.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.project_id.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Box
sx={{
minHeight: "100vh",
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)",
p: { xs: 2, md: 4 },
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: "auto",
borderRadius: 4,
border: "1px solid rgba(255,255,255,0.08)",
background: alpha("#0f172a", 0.7),
backdropFilter: "blur(25px)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" />
My Podcast Projects
</Typography>
<Typography variant="body2" color="text.secondary">
Resume your work or start a new episode
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
Refresh
</SecondaryButton>
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
New Episode
</PrimaryButton>
</Stack>
</Stack>
{/* Search */}
<TextField
fullWidth
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <SearchIcon sx={{ color: "rgba(255,255,255,0.5)", mr: 1 }} />,
}}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
},
}}
/>
{/* Error */}
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Loading */}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
)}
{/* Projects List */}
{!loading && filteredProjects.length === 0 && (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2} alignItems="center" sx={{ p: 4 }}>
<Typography variant="h6" color="text.secondary">
{searchQuery ? "No projects match your search" : "No projects yet"}
</Typography>
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
Create Your First Episode
</PrimaryButton>
</Stack>
</GlassyCard>
)}
{!loading && filteredProjects.length > 0 && (
<Stack spacing={2}>
{filteredProjects.map((project) => (
<GlassyCard
key={project.project_id}
sx={{
...glassyCardSx,
cursor: "pointer",
"&:hover": {
borderColor: "rgba(102,126,234,0.4)",
transform: "translateY(-2px)",
},
transition: "all 0.2s",
}}
onClick={() => onSelectProject(project.project_id)}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 1 }}>
{project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={getStepLabel(project.current_step)}
size="small"
color={getStatusColor(project.status)}
sx={{ background: alpha("#667eea", 0.2), color: "#a78bfa" }}
/>
<Typography variant="caption" color="text.secondary">
{project.speakers} {project.speakers === 1 ? "speaker" : "speakers"}
</Typography>
<Typography variant="caption" color="text.secondary">
{project.duration} min
</Typography>
<Typography variant="caption" color="text.secondary">
Updated {new Date(project.updated_at).toLocaleDateString()}
</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(project.project_id, project.is_favorite);
}}
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }}
>
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Delete project">
<IconButton
onClick={(e) => {
e.stopPropagation();
setProjectToDelete(project.project_id);
setDeleteDialogOpen(true);
}}
sx={{ color: "rgba(255,255,255,0.5)" }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Stack>
</GlassyCard>
))}
</Stack>
)}
</Stack>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
},
}}
>
<DialogTitle sx={{ color: "white" }}>Delete Project?</DialogTitle>
<DialogContent>
<Typography sx={{ color: "rgba(255,255,255,0.7)" }}>
Are you sure you want to delete this project? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<SecondaryButton onClick={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleDelete} startIcon={<DeleteIcon />}>
Delete
</PrimaryButton>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -0,0 +1,77 @@
import React from "react";
import { Stack, Box, Typography, Paper, Chip, alpha } from "@mui/material";
import { LibraryMusic as LibraryMusicIcon, OpenInNew as OpenInNewIcon, VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { useContentAssets } from "../../hooks/useContentAssets";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
interface RecentEpisodesPreviewProps {
onSelectEpisode: (assetId: number) => void;
}
export const RecentEpisodesPreview: React.FC<RecentEpisodesPreviewProps> = ({ onSelectEpisode }) => {
const navigate = useNavigate();
const { assets, loading } = useContentAssets({
asset_type: "audio",
source_module: "podcast_maker",
limit: 6,
});
if (loading || assets.length === 0) {
return null;
}
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<LibraryMusicIcon />
Recent Episodes
</Typography>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<OpenInNewIcon />}
>
View All
</SecondaryButton>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
{assets.slice(0, 6).map((asset) => (
<Paper
key={asset.id}
sx={{
p: 2,
background: alpha("#1e293b", 0.5),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
cursor: "pointer",
"&:hover": {
borderColor: "rgba(102,126,234,0.4)",
background: alpha("#1e293b", 0.7),
},
}}
onClick={() => onSelectEpisode(asset.id)}
>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{asset.title || "Untitled Episode"}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<VolumeUpIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
<Typography variant="caption" color="text.secondary">
{new Date(asset.created_at).toLocaleDateString()}
</Typography>
</Stack>
{asset.cost > 0 && (
<Chip label={`$${asset.cost.toFixed(2)}`} size="small" sx={{ width: "fit-content", fontSize: "0.65rem", height: 20 }} />
)}
</Stack>
</Paper>
))}
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,552 @@
import React, { useEffect, useState, useRef } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material";
import {
PlayArrow as PlayArrowIcon,
ArrowBack as ArrowBackIcon,
VolumeUp as VolumeUpIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Download as DownloadIcon,
Share as ShareIcon,
Refresh as RefreshIcon,
Videocam as VideocamIcon,
Cancel as CancelIcon,
} from "@mui/icons-material";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types";
import { podcastApi } from "../../services/podcastApi";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { InlineAudioPlayer } from "./InlineAudioPlayer";
interface RenderQueueProps {
projectId: string;
script: Script;
knobs: Knobs;
jobs: Job[];
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onBack: () => void;
onError: (message: string) => void;
}
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => {
const [rendering, setRendering] = useState<string | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
const isBusy = Boolean(rendering);
// Cleanup polling intervals on unmount
useEffect(() => {
return () => {
pollingIntervals.current.forEach((interval) => clearInterval(interval));
pollingIntervals.current.clear();
};
}, []);
// Initialize jobs if empty
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => ({
sceneId: s.id,
title: s.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
}));
// Update all jobs at once
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob]);
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
const pollTaskStatus = async (taskId: string, sceneId: string) => {
try {
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
const result = status.result;
const updates: Partial<Job> = {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
};
onUpdateJob(sceneId, updates);
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} else if (status.status === "failed") {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
onError(status.error || "Video generation failed");
}
return status.status === "completed" || status.status === "failed";
} catch (error) {
console.error("Error polling task status:", error);
return false;
}
};
const startPolling = (taskId: string, sceneId: string) => {
// Clear any existing interval for this scene
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
// Poll every 3 seconds
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
};
const cancelRender = async (sceneId: string) => {
const job = jobs.find((j) => j.sceneId === sceneId);
if (job?.taskId) {
try {
await podcastApi.cancelTask(job.taskId);
onUpdateJob(sceneId, { status: "cancelled", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} catch (error) {
console.error("Error cancelling task:", error);
onError("Failed to cancel render job");
}
}
};
const runRender = async (sceneId: string, mode: "preview" | "full") => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const job = jobs.find((j) => j.sceneId === sceneId);
if (job && job.status !== "idle") return;
const scene = getScene(sceneId);
if (!scene) return;
// Estimate cost (rough estimate: ~$0.05 per 1000 chars)
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: mode === "preview" ? "previewing" : "running",
progress: mode === "preview" ? 25 : 40,
});
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
const updates: Partial<Job> = {
status: "completed",
progress: 100,
cost: result.cost,
provider: result.provider,
voiceId: result.voiceId,
fileSize: result.fileSize,
};
if (mode === "preview") {
updates.previewUrl = result.audioUrl;
window.open(result.audioUrl, "_blank");
} else {
updates.finalUrl = result.audioUrl;
// Save to asset library when final render completes
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${script.scenes.find((s) => s.id === sceneId)?.title || "Scene"} - ${projectId}`,
description: `Podcast episode scene audio: ${scene.title}`,
projectId,
sceneId,
cost: result.cost,
provider: result.provider,
model: result.model,
fileSize: result.fileSize,
});
} catch (assetError) {
console.error("Failed to save to asset library:", assetError);
// Don't fail the render if asset save fails
}
}
onUpdateJob(sceneId, updates);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Render failed";
onError(message);
} finally {
setRendering(null);
}
};
const runVideoRender = async (sceneId: string) => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
if (!avatarImageUrl) {
onError("Avatar image is required for video generation. Please upload an avatar image in project settings.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
// Estimate cost (video generation is ~$0.30 per 5 seconds at 720p)
const estimatedCost = 0.30; // Base cost per video
// Check budget cap if provided
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: avatarImageUrl,
resolution: knobs.resolution || "720p",
});
// Start polling for video generation status
onUpdateJob(sceneId, {
taskId: result.taskId,
status: "running",
progress: 5,
});
startPolling(result.taskId, sceneId);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Video generation failed";
onError(message);
} finally {
setRendering(null);
}
};
const getStatusColor = (status: Job["status"]) => {
switch (status) {
case "completed":
return "success";
case "failed":
return "error";
case "running":
case "previewing":
return "info";
default:
return "default";
}
};
const getStatusIcon = (status: Job["status"]) => {
switch (status) {
case "completed":
return <CheckCircleIcon />;
case "failed":
return <InfoIcon />;
case "running":
case "previewing":
return <CircularProgress size={16} />;
default:
return <RadioButtonUncheckedIcon />;
}
};
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PlayArrowIcon />
Render Queue
</Typography>
</Stack>
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
</Typography>
</Alert>
<Stack spacing={2}>
{jobs.map((job) => {
const scene = getScene(job.sceneId);
const initials = job.title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
return (
<GlassyCard key={job.sceneId} sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Paper
sx={{
width: 56,
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
fontWeight: 700,
fontSize: "1.2rem",
}}
>
{initials}
</Paper>
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{job.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${job.sceneId.slice(-4)}`} size="small" variant="outlined" />
{job.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
/>
)}
{job.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
</Stack>
{job.finalUrl && (
<Button
size="small"
startIcon={<OpenInNewIcon />}
href={job.finalUrl}
target="_blank"
sx={{ mt: 1, color: "#a78bfa" }}
>
Download Final Audio
</Button>
)}
{job.videoUrl && (
<Button
size="small"
startIcon={<VideocamIcon />}
href={job.videoUrl}
target="_blank"
sx={{ mt: 1, ml: 1, color: "#a78bfa" }}
>
Download Video
</Button>
)}
</Box>
<Chip
icon={getStatusIcon(job.status)}
label={job.status.charAt(0).toUpperCase() + job.status.slice(1)}
color={getStatusColor(job.status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
</Stack>
{job.status !== "idle" && job.status !== "completed" && (
<Box>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{job.progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={job.progress}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#fff", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
/>
</Box>
)}
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
{job.status === "idle" && (
<>
<SecondaryButton
onClick={() => runRender(job.sceneId, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing before generating the full episode"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => runRender(job.sceneId, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</>
)}
{job.status === "completed" && (job.previewUrl || job.finalUrl) && (
<Stack spacing={1} sx={{ width: "100%" }}>
<InlineAudioPlayer audioUrl={job.finalUrl || job.previewUrl || ""} title={job.title} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => {
const link = document.createElement("a");
link.href = job.finalUrl || job.previewUrl || "";
link.download = `${job.title.replace(/\s+/g, "-")}.mp3`;
link.click();
}}
>
Download
</Button>
<Button
size="small"
variant="outlined"
startIcon={<ShareIcon />}
onClick={async () => {
if (navigator.share && job.finalUrl) {
try {
await navigator.share({
title: job.title,
text: `Check out this podcast episode: ${job.title}`,
url: job.finalUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(job.finalUrl || job.previewUrl || "");
alert("Audio URL copied to clipboard!");
}
}}
>
Share
</Button>
</Stack>
</Stack>
)}
{job.status === "failed" && (
<Button variant="outlined" color="warning" onClick={() => runRender(job.sceneId, "full")} startIcon={<RefreshIcon />}>
Retry
</Button>
)}
</Stack>
</Stack>
</GlassyCard>
);
})}
</Stack>
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from "react";
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { Line } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface LineEditorProps {
line: Line;
onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text);
const [previewing, setPreviewing] = useState(false);
useEffect(() => setText(line.text), [line.text]);
const handleSave = () => {
onChange({ ...line, text });
setEditing(false);
};
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await onPreview(text);
if (res.audioUrl) {
window.open(res.audioUrl, "_blank");
} else {
alert(res.message);
}
} finally {
setPreviewing(false);
}
};
return (
<GlassyCard
whileHover={{ y: -2 }}
sx={{
...glassyCardSx,
p: 2,
transition: "all 0.2s",
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} />
{editing ? (
<TextField
fullWidth
multiline
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
},
}}
/>
) : (
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
{line.text}
</Typography>
)}
{line.usedFactIds && line.usedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">
Facts:
</Typography>
{line.usedFactIds.map((id) => (
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
))}
</Stack>
)}
</Box>
<Stack spacing={1} sx={{ ml: 2 }}>
<Button
size="small"
variant={editing ? "contained" : "outlined"}
onClick={editing ? handleSave : () => setEditing(true)}
sx={{ minWidth: 80 }}
>
{editing ? "Save" : "Edit"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
onClick={handlePreview}
disabled={previewing || editing}
sx={{ minWidth: 120 }}
>
Preview TTS
</Button>
</Stack>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,86 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
} from "@mui/icons-material";
import { Scene, Line } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
interface SceneEditorProps {
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
approvingSceneId?: string | null;
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
scene,
onUpdateScene,
onApprove,
onPreviewLine,
approvingSceneId,
}) => {
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
};
const approving = approvingSceneId === scene.id;
const handleApprove = async () => {
await onApprove(scene.id);
onUpdateScene({ ...scene, approved: true });
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<EditNoteIcon fontSize="small" />
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: scene.approved ? "#6ee7b7" : "#fbbf24",
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
}}
/>
<Typography variant="caption" color="text.secondary">
Duration: {scene.duration}s
</Typography>
</Stack>
</Box>
<PrimaryButton
onClick={handleApprove}
disabled={scene.approved || approving}
loading={approving}
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
>
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
</PrimaryButton>
</Stack>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack spacing={2}>
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
))}
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useState } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
interface ScriptEditorProps {
projectId: string;
idea: string;
research: any; // Research type
rawResearch: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
script: Script | null;
onScriptChange: (script: Script) => void;
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
projectId,
idea,
research,
rawResearch,
knobs,
speakers,
durationMinutes,
script: initialScript,
onScriptChange,
onBackToResearch,
onProceedToRendering,
onError,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
})
.then((res) => {
if (mounted) {
setScript(res);
onScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
const updateScene = (updated: Scene) => {
if (!script) return;
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
setScript(updatedScript);
onScriptChange(updatedScript);
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
const updatedScript = script
? {
...script,
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
}
: null;
if (updatedScript) {
setScript(updatedScript);
onScriptChange(updatedScript);
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
onError(message);
throw err;
} finally {
setApprovingSceneId((current) => (current === sceneId ? null : current));
}
};
const allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0;
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<EditNoteIcon />
Script Editor
</Typography>
</Stack>
{loading && (
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{script && (
<Stack spacing={3}>
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography>
</Alert>
<Stack spacing={2}>
{script.scenes.map((scene, idx) => (
<GlassyCard
key={scene.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: idx * 0.1 }}
>
<SceneEditor
scene={scene}
onUpdateScene={updateScene}
onApprove={approveScene}
onPreviewLine={(text) => podcastApi.previewLine(text)}
approvingSceneId={approvingSceneId}
/>
</GlassyCard>
))}
</Stack>
<Paper
sx={{
p: 3,
background: alpha("#1e293b", 0.6),
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
Approval Status
</Typography>
<Typography variant="body2" color="text.secondary">
{approvedCount} of {totalScenes} scenes approved
{!allApproved && " — Approve all scenes to enable rendering"}
</Typography>
{!allApproved && (
<LinearProgress
variant="determinate"
value={(approvedCount / totalScenes) * 100}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
)}
</Box>
<PrimaryButton
onClick={() => script && onProceedToRendering(script)}
disabled={!allApproved}
startIcon={<PlayArrowIcon />}
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
</Stack>
)}
</Box>
);
};

View File

@@ -0,0 +1,4 @@
export { LineEditor } from "./LineEditor";
export { SceneEditor } from "./SceneEditor";
export { ScriptEditor } from "./ScriptEditor";

View File

@@ -67,11 +67,14 @@ export type Job = {
progress: number;
previewUrl?: string | null;
finalUrl?: string | null;
videoUrl?: string | null;
jobId?: string | null;
taskId?: string | null;
cost?: number | null;
provider?: string | null;
voiceId?: string | null;
fileSize?: number | null;
avatarImageUrl?: string | null;
};
export type PodcastAnalysis = {
@@ -115,5 +118,18 @@ export type RenderJobResult = {
cost: number;
voiceId: string;
fileSize: number;
videoUrl?: string;
videoFilename?: string;
};
export type TaskStatus = {
task_id: string;
status: "pending" | "processing" | "completed" | "failed";
progress?: number;
message?: string;
result?: any;
error?: string;
created_at?: string;
updated_at?: string;
};

View File

@@ -0,0 +1,14 @@
import React from "react";
import { motion } from "framer-motion";
import { Paper, alpha } from "@mui/material";
export const GlassyCard = motion(Paper);
export const glassyCardSx = {
borderRadius: 2,
border: "1px solid rgba(0,0,0,0.08)",
background: "#ffffff",
p: 2.5,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
};

View File

@@ -0,0 +1,58 @@
import React from "react";
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
interface PrimaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
}
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
children,
onClick,
disabled = false,
loading = false,
startIcon,
tooltip,
ariaLabel,
}) => {
const button = (
<Button
variant="contained"
onClick={onClick}
disabled={disabled || loading}
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
aria-label={ariaLabel}
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
color: "white",
fontWeight: 600,
textTransform: "none",
px: 3,
py: 1,
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
"&:disabled": {
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
}}
>
{children}
</Button>
);
return tooltip ? (
<Tooltip title={tooltip} arrow>
<span>{button}</span>
</Tooltip>
) : (
button
);
};

View File

@@ -0,0 +1,52 @@
import React from "react";
import { Button, Tooltip, alpha } from "@mui/material";
interface SecondaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
}
export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
children,
onClick,
disabled = false,
startIcon,
tooltip,
ariaLabel,
}) => {
const button = (
<Button
variant="outlined"
onClick={onClick}
disabled={disabled}
startIcon={startIcon}
aria-label={ariaLabel}
sx={{
borderColor: "rgba(255,255,255,0.2)",
color: "rgba(255,255,255,0.9)",
textTransform: "none",
px: 2.5,
py: 0.75,
"&:hover": {
borderColor: "rgba(255,255,255,0.4)",
background: alpha("#fff", 0.05),
},
}}
>
{children}
</Button>
);
return tooltip ? (
<Tooltip title={tooltip} arrow>
<span>{button}</span>
</Tooltip>
) : (
button
);
};

View File

@@ -0,0 +1,4 @@
export { GlassyCard, glassyCardSx } from "./GlassyCard";
export { PrimaryButton } from "./PrimaryButton";
export { SecondaryButton } from "./SecondaryButton";

View File

@@ -477,10 +477,12 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
variant="contained"
onClick={handleCreate}
disabled={
isCreatingBlueprint ||
isGeneratingProposals ||
isValidatingPreflight ||
(preflightResult && !preflightResult.can_proceed)
Boolean(
isCreatingBlueprint ||
isGeneratingProposals ||
isValidatingPreflight ||
(preflightResult ? !preflightResult.can_proceed : false)
)
}
startIcon={
isCreatingBlueprint || isGeneratingProposals ? (

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Box, TextField, Stack, Typography } from '@mui/material';
import { Product as ProductIcon } from '@mui/icons-material';
import { Inventory2 as ProductIcon } from '@mui/icons-material';
interface ProductInfoFormProps {
productName: string;

View File

@@ -0,0 +1,453 @@
/**
* YouTube Creator Studio Component
*
* AI-first YouTube video creation tool with persona integration.
* Three-phase workflow: Plan → Scenes → Render
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Container,
Typography,
Stepper,
Step,
StepLabel,
Paper,
Button,
Alert,
} from '@mui/material';
import { ArrowBack } from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { youtubeApi, type VideoPlan, type Scene } from '../../services/youtubeApi';
import { STEPS, YT_RED, YT_BG, YT_BORDER, YT_TEXT, type Resolution, type DurationType } from './constants';
import { PlanStep } from './components/PlanStep';
import { ScenesStep } from './components/ScenesStep';
import { RenderStep } from './components/RenderStep';
import { useRenderPolling } from './hooks/useRenderPolling';
import { useCostEstimate } from './hooks/useCostEstimate';
import HeaderControls from '../shared/HeaderControls';
const YouTubeCreator: React.FC = () => {
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Step 1: Plan
const [userIdea, setUserIdea] = useState('');
const [durationType, setDurationType] = useState<DurationType>('medium');
const [referenceImage, setReferenceImage] = useState('');
const [videoPlan, setVideoPlan] = useState<VideoPlan | null>(null);
// Step 2: Scenes
const [scenes, setScenes] = useState<Scene[]>([]);
const [editingSceneId, setEditingSceneId] = useState<number | null>(null);
const [editedScene, setEditedScene] = useState<Partial<Scene> | null>(null);
// Step 3: Render
const [renderTaskId, setRenderTaskId] = useState<string | null>(null);
const [renderStatus, setRenderStatus] = useState<any>(null);
const [renderProgress, setRenderProgress] = useState(0);
const [resolution, setResolution] = useState<Resolution>('720p');
const [combineScenes, setCombineScenes] = useState(true);
// Custom hooks
const { renderStatus: polledStatus, renderProgress: polledProgress, error: pollingError } = useRenderPolling(
renderTaskId,
() => setSuccess('Video rendered successfully!'),
(err) => setError(err)
);
// Update local state from polling hook
React.useEffect(() => {
if (polledStatus) {
setRenderStatus(polledStatus);
}
if (polledProgress !== undefined) {
setRenderProgress(polledProgress);
}
if (pollingError) {
setError(pollingError);
}
}, [polledStatus, polledProgress, pollingError]);
const { costEstimate, loadingCostEstimate } = useCostEstimate({
activeStep,
scenes,
resolution,
renderTaskId,
});
// Memoized computed values
const enabledScenesCount = useMemo(
() => scenes.filter(s => s.enabled !== false).length,
[scenes]
);
// Handlers
const handleGeneratePlan = useCallback(async () => {
if (!userIdea.trim()) {
setError('Please enter your video idea');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.createPlan({
user_idea: userIdea,
duration_type: durationType,
reference_image_description: referenceImage || undefined,
});
if (response.success && response.plan) {
setVideoPlan(response.plan);
setSuccess('Video plan generated successfully!');
setTimeout(() => {
setActiveStep(1);
setSuccess(null);
}, 1000);
} else {
setError(response.message || 'Failed to generate plan');
}
} catch (err: any) {
setError(err.message || 'Failed to generate video plan');
} finally {
setLoading(false);
}
}, [userIdea, durationType, referenceImage]);
const handleBuildScenes = useCallback(async () => {
if (!videoPlan) {
setError('Please generate a plan first');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.buildScenes(videoPlan);
if (response.success && response.scenes) {
setScenes(response.scenes.map(s => ({ ...s, enabled: s.enabled !== false })));
setSuccess(`Built ${response.scenes.length} scenes successfully!`);
setTimeout(() => {
setActiveStep(2);
setSuccess(null);
}, 1000);
} else {
setError(response.message || 'Failed to build scenes');
}
} catch (err: any) {
setError(err.message || 'Failed to build scenes');
} finally {
setLoading(false);
}
}, [videoPlan]);
const handleEditScene = useCallback((scene: Scene) => {
setEditingSceneId(scene.scene_number);
setEditedScene({
narration: scene.narration,
visual_prompt: scene.visual_prompt,
duration_estimate: scene.duration_estimate,
enabled: scene.enabled !== false,
});
}, []);
const handleSaveScene = useCallback(async () => {
if (!editingSceneId || !editedScene) return;
setLoading(true);
setError(null);
try {
const response = await youtubeApi.updateScene(editingSceneId, {
narration: editedScene.narration,
visual_description: editedScene.visual_prompt,
duration_estimate: editedScene.duration_estimate,
enabled: editedScene.enabled,
});
if (response.success && response.scene) {
setScenes(scenes.map(s =>
s.scene_number === editingSceneId ? { ...s, ...response.scene } : s
));
setEditingSceneId(null);
setEditedScene(null);
setSuccess('Scene updated successfully!');
} else {
setError(response.message || 'Failed to update scene');
}
} catch (err: any) {
setError(err.message || 'Failed to update scene');
} finally {
setLoading(false);
}
}, [editingSceneId, editedScene, scenes]);
const handleCancelEdit = useCallback(() => {
setEditingSceneId(null);
setEditedScene(null);
}, []);
const handleToggleScene = useCallback((sceneNumber: number) => {
setScenes(scenes.map(s =>
s.scene_number === sceneNumber ? { ...s, enabled: !s.enabled } : s
));
}, [scenes]);
const handleStartRender = useCallback(async () => {
if (scenes.length === 0) {
setError('Please build scenes first');
return;
}
const enabledScenes = scenes.filter(s => s.enabled !== false);
if (enabledScenes.length === 0) {
setError('Please enable at least one scene to render');
return;
}
if (!videoPlan) {
setError('Video plan is missing');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.startRender({
scenes: enabledScenes,
video_plan: videoPlan,
resolution,
combine_scenes: combineScenes,
});
if (response.success && response.task_id) {
setRenderTaskId(response.task_id);
setRenderProgress(0);
setSuccess('Video rendering started!');
} else {
setError(response.message || 'Failed to start render');
}
} catch (err: any) {
setError(err.message || 'Failed to start render');
} finally {
setLoading(false);
}
}, [scenes, videoPlan, resolution, combineScenes]);
const getVideoUrl = useCallback(() => {
if (renderStatus?.result?.final_video_url) {
return renderStatus.result.final_video_url;
}
if (renderStatus?.result?.scene_results?.[0]?.video_url) {
return renderStatus.result.scene_results[0].video_url;
}
return null;
}, [renderStatus]);
const handleStepNavigation = useCallback((targetStep: number) => {
if (targetStep === activeStep) return;
// Always allow going back
if (targetStep < activeStep) {
setActiveStep(targetStep);
return;
}
// Forward navigation with guards
if (targetStep === 1) {
if (!videoPlan) {
setError('Please generate a plan first.');
return;
}
setActiveStep(1);
return;
}
if (targetStep === 2) {
if (!videoPlan) {
setError('Please generate a plan first.');
return;
}
if (scenes.length === 0) {
setError('Please build scenes before rendering.');
return;
}
if (enabledScenesCount === 0) {
setError('Enable at least one scene to render.');
return;
}
setActiveStep(2);
return;
}
}, [activeStep, videoPlan, scenes.length, enabledScenesCount]);
const handleResetRender = useCallback(() => {
setRenderTaskId(null);
setRenderStatus(null);
setRenderProgress(0);
setError(null);
}, []);
const handleRetryFailedScenes = useCallback((failedScenes: any[]) => {
if (failedScenes.length > 0) {
const sceneNumbers = failedScenes.map((f: any) => f.scene_number);
const updatedScenes = scenes.map(s =>
sceneNumbers.includes(s.scene_number)
? { ...s, enabled: true }
: s
);
setScenes(updatedScenes);
handleResetRender();
}
}, [scenes, handleResetRender]);
return (
<Container
maxWidth="lg"
sx={{
py: 4,
backgroundColor: YT_BG,
color: YT_TEXT,
minHeight: '100vh',
borderRadius: 2,
border: `1px solid ${YT_BORDER}`,
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
}}
>
{/* Header */}
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/dashboard')}
variant="outlined"
sx={{ borderColor: YT_BORDER, color: YT_TEXT, backgroundColor: 'white' }}
>
Back to Dashboard
</Button>
<Typography variant="h4" sx={{ flexGrow: 1, fontWeight: 700 }}>
🎥 YouTube Creator Studio
</Typography>
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
</Box>
{/* Stepper */}
<Paper
sx={{
p: 3,
mb: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Stepper
activeStep={activeStep}
sx={{
'& .MuiStepIcon-root.Mui-active': { color: YT_RED },
'& .MuiStepIcon-root.Mui-completed': { color: YT_RED },
}}
>
{STEPS.map((label, idx) => (
<Step key={label} completed={idx < activeStep}>
<StepLabel
onClick={() => handleStepNavigation(idx)}
sx={{ cursor: 'pointer', userSelect: 'none' }}
>
{label}
</StepLabel>
</Step>
))}
</Stepper>
</Paper>
{/* Success Alert */}
<AnimatePresence>
{success && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
</motion.div>
)}
</AnimatePresence>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Step Components */}
{activeStep === 0 && (
<PlanStep
userIdea={userIdea}
durationType={durationType}
referenceImage={referenceImage}
loading={loading}
onIdeaChange={setUserIdea}
onDurationChange={setDurationType}
onReferenceImageChange={setReferenceImage}
onGeneratePlan={handleGeneratePlan}
/>
)}
{activeStep === 1 && videoPlan && (
<ScenesStep
videoPlan={videoPlan}
scenes={scenes}
editingSceneId={editingSceneId}
editedScene={editedScene}
loading={loading}
onBuildScenes={handleBuildScenes}
onEditScene={handleEditScene}
onSaveScene={handleSaveScene}
onCancelEdit={handleCancelEdit}
onEditChange={setEditedScene}
onToggleScene={handleToggleScene}
onBack={() => setActiveStep(0)}
onNext={() => setActiveStep(2)}
/>
)}
{activeStep === 2 && (
<RenderStep
renderTaskId={renderTaskId}
renderStatus={renderStatus}
renderProgress={renderProgress}
resolution={resolution}
combineScenes={combineScenes}
enabledScenesCount={enabledScenesCount}
costEstimate={costEstimate}
loadingCostEstimate={loadingCostEstimate}
loading={loading}
onResolutionChange={setResolution}
onCombineScenesChange={setCombineScenes}
onStartRender={handleStartRender}
onBack={() => setActiveStep(1)}
onReset={handleResetRender}
onRetryFailedScenes={handleRetryFailedScenes}
getVideoUrl={getVideoUrl}
/>
)}
</Container>
);
};
export default YouTubeCreator;

View File

@@ -0,0 +1,138 @@
/**
* Plan Details Component
*/
import React from 'react';
import { Paper, Typography, Stack, Box, Grid, Chip } from '@mui/material';
import { VideoPlan } from '../../../services/youtubeApi';
import { YT_BORDER, YT_TEXT } from '../constants';
interface PlanDetailsProps {
plan: VideoPlan;
}
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan }) => {
return (
<Paper
elevation={0}
sx={{
mb: 3,
p: 2.5,
border: `1px solid ${YT_BORDER}`,
backgroundColor: '#fff',
borderRadius: 2,
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: YT_TEXT }}>
Plan Details
</Typography>
<Stack spacing={1.25}>
{plan.video_summary && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Summary
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.video_summary}
</Typography>
</Box>
)}
<Grid container spacing={2}>
{plan.target_audience && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Target Audience
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.target_audience}
</Typography>
</Grid>
)}
{plan.video_goal && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Goal
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.video_goal}
</Typography>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{plan.key_message && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Key Message
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.key_message}
</Typography>
</Grid>
)}
{plan.call_to_action && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Call to Action
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.call_to_action}
</Typography>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{plan.hook_strategy && (
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Hook Strategy
</Typography>
<Typography variant="body2" color="text.secondary">
{plan.hook_strategy}
</Typography>
</Grid>
)}
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
Style & Tone
</Typography>
<Typography variant="body2" color="text.secondary">
Visual Style: {plan.visual_style || '—'} | Tone: {plan.tone || '—'}
</Typography>
</Grid>
</Grid>
{plan.seo_keywords && plan.seo_keywords.length > 0 && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
SEO Keywords
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{plan.seo_keywords.map((kw, idx) => (
<Chip key={`${kw}-${idx}`} label={kw} size="small" />
))}
</Stack>
</Box>
)}
{plan.content_outline && plan.content_outline.length > 0 && (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
Content Outline
</Typography>
<Stack spacing={0.75}>
{plan.content_outline.map((item, idx) => (
<Typography key={idx} variant="body2" color="text.secondary">
{item.section || `Section ${idx + 1}`} {item.description || 'Description missing'} ({item.duration_estimate || 0}s)
</Typography>
))}
</Stack>
</Box>
)}
</Stack>
</Paper>
);
});
PlanDetails.displayName = 'PlanDetails';

View File

@@ -0,0 +1,126 @@
/**
* Plan Step Component
*/
import React from 'react';
import {
Paper,
Typography,
TextField,
Button,
Stack,
FormControl,
InputLabel,
Select,
MenuItem,
FormHelperText,
CircularProgress,
} from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { inputSx, labelSx, helperSx, selectSx } from '../styles';
import { DurationType } from '../constants';
interface PlanStepProps {
userIdea: string;
durationType: DurationType;
referenceImage: string;
loading: boolean;
onIdeaChange: (idea: string) => void;
onDurationChange: (duration: DurationType) => void;
onReferenceImageChange: (image: string) => void;
onGeneratePlan: () => void;
}
export const PlanStep: React.FC<PlanStepProps> = React.memo(({
userIdea,
durationType,
referenceImage,
loading,
onIdeaChange,
onDurationChange,
onReferenceImageChange,
onGeneratePlan,
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: '1px solid #e5e5e5',
}}
>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
1 Plan Your Video
</Typography>
<Stack spacing={3}>
<TextField
label="What's your video about?"
placeholder="Example: 'AI explains black holes in 60 seconds' or 'Budget travel guide for Tokyo'"
value={userIdea}
onChange={(e) => onIdeaChange(e.target.value)}
multiline
rows={4}
fullWidth
required
helperText="Describe the story in one to two sentences. Include audience, outcome, and hook. Tip: name the platform goal (views, subs, clicks)."
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
FormHelperTextProps={{ sx: helperSx }}
/>
<FormControl fullWidth>
<InputLabel sx={labelSx}>Video Duration</InputLabel>
<Select
value={durationType}
label="Video Duration"
onChange={(e) => onDurationChange(e.target.value as DurationType)}
sx={selectSx}
>
<MenuItem value="shorts">Shorts (15-60 seconds)</MenuItem>
<MenuItem value="medium">Medium (1-4 minutes)</MenuItem>
<MenuItem value="long">Long (4-10 minutes)</MenuItem>
</Select>
<FormHelperText>
Shorts = vertical bite-sized (60s). Medium = quick explainers. Long = deep dives.
</FormHelperText>
</FormControl>
<TextField
label="Reference Image Description (Optional)"
placeholder="Example: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or paste image keywords"
value={referenceImage}
onChange={(e) => onReferenceImageChange(e.target.value)}
multiline
rows={2}
fullWidth
helperText="Optional: Describe visual cues or style you want the visuals to follow."
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
FormHelperTextProps={{ sx: helperSx }}
/>
<Button
variant="contained"
color="error"
size="large"
onClick={onGeneratePlan}
disabled={loading || !userIdea.trim()}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
sx={{ alignSelf: 'flex-start', px: 4 }}
>
{loading ? 'Generating Plan...' : 'Generate Video Plan'}
</Button>
</Stack>
</Paper>
</motion.div>
);
});
PlanStep.displayName = 'PlanStep';

View File

@@ -0,0 +1,339 @@
/**
* Render Step Component
*/
import React from 'react';
import {
Paper,
Typography,
Stack,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Button,
Box,
Alert,
LinearProgress,
CircularProgress,
Typography as MuiTypography,
} from '@mui/material';
import { PlayArrow, Download, Refresh } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { TaskStatus, CostEstimate } from '../../../services/youtubeApi';
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
interface RenderStepProps {
renderTaskId: string | null;
renderStatus: TaskStatus | null;
renderProgress: number;
resolution: Resolution;
combineScenes: boolean;
enabledScenesCount: number;
costEstimate: CostEstimate | null;
loadingCostEstimate: boolean;
loading: boolean;
onResolutionChange: (resolution: Resolution) => void;
onCombineScenesChange: (combine: boolean) => void;
onStartRender: () => void;
onBack: () => void;
onReset: () => void;
onRetryFailedScenes: (failedScenes: any[]) => void;
getVideoUrl: () => string | null;
}
export const RenderStep: React.FC<RenderStepProps> = React.memo(({
renderTaskId,
renderStatus,
renderProgress,
resolution,
combineScenes,
enabledScenesCount,
costEstimate,
loadingCostEstimate,
loading,
onResolutionChange,
onCombineScenesChange,
onStartRender,
onBack,
onReset,
onRetryFailedScenes,
getVideoUrl,
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
3 Render Video
</Typography>
{!renderTaskId ? (
<Stack spacing={3}>
<Alert severity="info">
Configure render settings and start generating your video. This may take several minutes.
</Alert>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Video Resolution</InputLabel>
<Select
value={resolution}
label="Video Resolution"
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
>
{RESOLUTIONS.map((res) => (
<MenuItem key={res} value={res}>
{res === '480p' && '480p (Lower cost, faster)'}
{res === '720p' && '720p (Recommended)'}
{res === '1080p' && '1080p (Highest quality)'}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={combineScenes}
onChange={(e) => onCombineScenesChange(e.target.checked)}
/>
}
label="Combine scenes into single video"
/>
</Grid>
</Grid>
<Box sx={{ p: 2, bgcolor: '#f4f4f4', borderRadius: 1, border: `1px solid ${YT_BORDER}` }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Render Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{enabledScenesCount} scenes will be rendered
<br />
Resolution: {resolution}
<br />
{combineScenes ? 'Scenes will be combined into one video' : 'Each scene will be a separate video'}
<br />
</Typography>
{/* Cost Estimate */}
{loadingCostEstimate ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Calculating cost estimate...
</Typography>
</Box>
) : costEstimate ? (
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.light', borderRadius: 1, border: '1px solid', borderColor: 'primary.main' }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: 'primary.dark' }}>
💰 Estimated Cost
</Typography>
<Typography variant="h6" sx={{ mb: 1, color: 'primary.dark' }}>
${costEstimate.total_cost.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Range: ${costEstimate.estimated_cost_range.min.toFixed(2)} - ${costEstimate.estimated_cost_range.max.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{costEstimate.num_scenes} scenes × ${costEstimate.price_per_second.toFixed(2)}/second
<br />
Total duration: ~{Math.round(costEstimate.total_duration_seconds)} seconds
<br />
Price per second: ${costEstimate.price_per_second.toFixed(2)} ({costEstimate.resolution})
</Typography>
{costEstimate.scene_costs.length > 0 && (
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Per Scene Breakdown:
</Typography>
{costEstimate.scene_costs.slice(0, 5).map((sceneCost) => (
<Typography key={sceneCost.scene_number} variant="caption" color="text.secondary" sx={{ display: 'block' }}>
Scene {sceneCost.scene_number}: {sceneCost.actual_duration}s = ${sceneCost.cost.toFixed(2)}
</Typography>
))}
{costEstimate.scene_costs.length > 5 && (
<Typography variant="caption" color="text.secondary">
... and {costEstimate.scene_costs.length - 5} more scenes
</Typography>
)}
</Box>
)}
</Box>
) : (
<Alert severity="warning" sx={{ mt: 2 }}>
Unable to calculate cost estimate. Please check your scenes and try again.
</Alert>
)}
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="outlined" onClick={onBack}>
Back to Scenes
</Button>
<Button
variant="contained"
color="error"
size="large"
onClick={onStartRender}
disabled={loading || enabledScenesCount === 0}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
sx={{ px: 4 }}
>
{loading ? 'Starting Render...' : 'Start Video Render'}
</Button>
</Box>
</Stack>
) : (
<Stack spacing={3}>
{renderStatus && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{renderStatus.message || 'Processing...'}
</Typography>
<Typography variant="body2" color="text.secondary">
{Math.round(renderProgress)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={renderProgress} sx={{ height: 8, borderRadius: 1 }} />
</Box>
{renderStatus.status === 'completed' && renderStatus.result && (
<Alert severity="success">
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Video Rendered Successfully!
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
Total cost: ${renderStatus.result.total_cost?.toFixed(2) || '0.00'}
<br />
Scenes rendered: {renderStatus.result.num_scenes || 0}
</Typography>
{getVideoUrl() && (
<Box sx={{ mt: 2 }}>
<video
controls
src={getVideoUrl()!}
style={{ width: '100%', maxHeight: '500px', borderRadius: 8 }}
/>
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
<Button
variant="contained"
startIcon={<Download />}
href={getVideoUrl()!}
download
>
Download Video
</Button>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={onReset}
>
Render Another
</Button>
</Box>
</Box>
)}
</Alert>
)}
{renderStatus.status === 'failed' && (
<Alert severity="error">
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Render Failed
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{renderStatus.error || 'An error occurred during rendering'}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
size="small"
startIcon={<Refresh />}
onClick={onReset}
>
Retry Render
</Button>
<Button
variant="outlined"
size="small"
onClick={onReset}
>
Start Over
</Button>
</Box>
</Alert>
)}
{renderStatus.status === 'completed' && renderStatus.result?.partial_success && (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Partial Success
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{renderStatus.result.num_scenes} scenes rendered successfully, but{' '}
{renderStatus.result.num_failed} scene(s) failed.
{renderStatus.result.failed_scenes && renderStatus.result.failed_scenes.length > 0 && (
<>
<br />
<br />
<strong>Failed Scenes:</strong>
{renderStatus.result.failed_scenes.map((failed: any, idx: number) => (
<Box key={idx} sx={{ mt: 1, p: 1, bgcolor: 'error.light', borderRadius: 1 }}>
<Typography variant="caption">
Scene {failed.scene_number}: {failed.error || 'Unknown error'}
</Typography>
</Box>
))}
</>
)}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="contained"
size="small"
startIcon={<Refresh />}
onClick={() => {
const failedScenes = renderStatus.result?.failed_scenes || [];
onRetryFailedScenes(failedScenes);
}}
>
Retry Failed Scenes
</Button>
<Button
variant="outlined"
size="small"
onClick={onReset}
>
View Successful Scenes
</Button>
</Box>
</Alert>
)}
</>
)}
</Stack>
)}
</Paper>
</motion.div>
);
});
RenderStep.displayName = 'RenderStep';

View File

@@ -0,0 +1,180 @@
/**
* Scene Card Component
*/
import React from 'react';
import {
Card,
CardContent,
Typography,
Stack,
Chip,
Box,
FormControlLabel,
Switch,
IconButton,
TextField,
Button,
} from '@mui/material';
import { Edit, Check, Close } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
import { inputSx, labelSx } from '../styles';
interface SceneCardProps {
scene: Scene;
isEditing: boolean;
editedScene: Partial<Scene> | null;
onToggle: (sceneNumber: number) => void;
onEdit: (scene: Scene) => void;
onSave: () => void;
onCancel: () => void;
onEditChange: (updates: Partial<Scene>) => void;
loading: boolean;
}
export const SceneCard: React.FC<SceneCardProps> = React.memo(({
scene,
isEditing,
editedScene,
onToggle,
onEdit,
onSave,
onCancel,
onEditChange,
loading,
}) => {
const sceneData = isEditing && editedScene ? { ...scene, ...editedScene } : scene;
return (
<Card
variant="outlined"
sx={{
opacity: sceneData.enabled === false ? 0.6 : 1,
border: sceneData.enabled === false ? '1px dashed' : '1px solid',
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Scene {scene.scene_number}: {sceneData.title}
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
{sceneData.emphasis_tags?.map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
color={
tag === 'hook' ? 'primary' :
tag === 'cta' ? 'secondary' : 'default'
}
/>
))}
<Chip
label={`~${sceneData.duration_estimate}s`}
size="small"
variant="outlined"
/>
</Stack>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={sceneData.enabled !== false}
onChange={() => onToggle(scene.scene_number)}
size="small"
/>
}
label="Enable"
sx={{ mr: 1 }}
/>
{!isEditing && (
<IconButton
size="small"
onClick={() => onEdit(scene)}
color="primary"
>
<Edit fontSize="small" />
</IconButton>
)}
</Box>
</Box>
{isEditing ? (
<Stack spacing={2}>
<TextField
label="Narration"
value={sceneData.narration}
onChange={(e) => onEditChange({ narration: e.target.value })}
multiline
rows={3}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<TextField
label="Visual Prompt"
value={sceneData.visual_prompt}
onChange={(e) => onEditChange({ visual_prompt: e.target.value })}
multiline
rows={2}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<TextField
label="Duration (seconds)"
type="number"
value={sceneData.duration_estimate}
onChange={(e) => onEditChange({ duration_estimate: parseFloat(e.target.value) || 5 })}
inputProps={{ min: 1, max: 10, step: 0.5 }}
fullWidth
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<Check />}
onClick={onSave}
disabled={loading}
>
Save
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
Cancel
</Button>
</Box>
</Stack>
) : (
<>
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic', color: 'text.secondary' }}>
"{sceneData.narration}"
</Typography>
<Typography variant="caption" color="text.secondary">
Visual: {sceneData.visual_prompt}
</Typography>
{sceneData.visual_cues && sceneData.visual_cues.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Cues: {sceneData.visual_cues.join(', ')}
</Typography>
</Box>
)}
</>
)}
</CardContent>
</Card>
);
});
SceneCard.displayName = 'SceneCard';

View File

@@ -0,0 +1,140 @@
/**
* Scenes Step Component
*/
import React, { useMemo } from 'react';
import {
Paper,
Typography,
Button,
Stack,
Box,
CircularProgress,
} from '@mui/material';
import { PlayArrow, VideoLibrary } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { VideoPlan, Scene } from '../../../services/youtubeApi';
import { PlanDetails } from './PlanDetails';
import { SceneCard } from './SceneCard';
import { YT_BORDER } from '../constants';
interface ScenesStepProps {
videoPlan: VideoPlan;
scenes: Scene[];
editingSceneId: number | null;
editedScene: Partial<Scene> | null;
loading: boolean;
onBuildScenes: () => void;
onEditScene: (scene: Scene) => void;
onSaveScene: () => void;
onCancelEdit: () => void;
onEditChange: (updates: Partial<Scene>) => void;
onToggleScene: (sceneNumber: number) => void;
onBack: () => void;
onNext: () => void;
}
export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
videoPlan,
scenes,
editingSceneId,
editedScene,
loading,
onBuildScenes,
onEditScene,
onSaveScene,
onCancelEdit,
onEditChange,
onToggleScene,
onBack,
onNext,
}) => {
const enabledScenesCount = useMemo(
() => scenes.filter(s => s.enabled !== false).length,
[scenes]
);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Paper
sx={{
p: 4,
backgroundColor: 'white',
border: `1px solid ${YT_BORDER}`,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
2 Review & Edit Scenes
</Typography>
{scenes.length === 0 && (
<Button
variant="contained"
color="error"
onClick={onBuildScenes}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
>
{loading ? 'Building Scenes...' : 'Build Scenes from Plan'}
</Button>
)}
</Box>
<PlanDetails plan={videoPlan} />
{scenes.length > 0 ? (
<Stack spacing={2}>
{scenes.map((scene) => (
<SceneCard
key={scene.scene_number}
scene={scene}
isEditing={editingSceneId === scene.scene_number}
editedScene={editedScene}
onToggle={onToggleScene}
onEdit={onEditScene}
onSave={onSaveScene}
onCancel={onCancelEdit}
onEditChange={onEditChange}
loading={loading}
/>
))}
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<VideoLibrary sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Click "Build Scenes from Plan" to generate scene-by-scene breakdown
</Typography>
</Box>
)}
{scenes.length > 0 && (
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'space-between' }}>
<Button variant="outlined" onClick={onBack}>
Back to Plan
</Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
{enabledScenesCount} of {scenes.length} scenes enabled
</Typography>
<Button
variant="contained"
color="error"
onClick={onNext}
disabled={enabledScenesCount === 0}
>
Proceed to Render ({enabledScenesCount} scenes)
</Button>
</Box>
</Box>
)}
</Paper>
</motion.div>
);
});
ScenesStep.displayName = 'ScenesStep';

View File

@@ -0,0 +1,19 @@
/**
* Constants for YouTube Creator Studio
*/
export const YT_RED = '#FF0000';
export const YT_BG = '#f9f9f9';
export const YT_BORDER = '#e5e5e5';
export const YT_TEXT = '#0f0f0f';
export const STEPS = ['Plan Your Video', 'Review Scenes', 'Render Video'] as const;
export const RESOLUTIONS = ['480p', '720p', '1080p'] as const;
export type Resolution = typeof RESOLUTIONS[number];
export const DURATION_TYPES = ['shorts', 'medium', 'long'] as const;
export type DurationType = typeof DURATION_TYPES[number];
export const POLLING_INTERVAL_MS = 2000; // 2 seconds

View File

@@ -0,0 +1,47 @@
/**
* Custom hook for fetching cost estimates
*/
import { useEffect, useState } from 'react';
import { youtubeApi, type Scene, type CostEstimate } from '../../../services/youtubeApi';
import { type Resolution } from '../constants';
interface UseCostEstimateParams {
activeStep: number;
scenes: Scene[];
resolution: Resolution;
renderTaskId: string | null;
}
export const useCostEstimate = ({ activeStep, scenes, resolution, renderTaskId }: UseCostEstimateParams) => {
const [costEstimate, setCostEstimate] = useState<CostEstimate | null>(null);
const [loadingCostEstimate, setLoadingCostEstimate] = useState(false);
useEffect(() => {
if (activeStep === 2 && scenes.length > 0 && !renderTaskId) {
const fetchCostEstimate = async () => {
setLoadingCostEstimate(true);
try {
const enabledScenes = scenes.filter(s => s.enabled !== false);
const response = await youtubeApi.estimateCost({
scenes: enabledScenes,
resolution: resolution,
});
if (response.success && response.estimate) {
setCostEstimate(response.estimate);
}
} catch (err: any) {
console.error('Error estimating cost:', err);
setCostEstimate(null);
} finally {
setLoadingCostEstimate(false);
}
};
fetchCostEstimate();
}
}, [activeStep, scenes, resolution, renderTaskId]);
return { costEstimate, loadingCostEstimate };
};

View File

@@ -0,0 +1,126 @@
/**
* Custom hook for polling render task status
*/
import { useEffect, useRef, useState } from 'react';
import { youtubeApi, type TaskStatus } from '../../../services/youtubeApi';
import { POLLING_INTERVAL_MS } from '../constants';
interface UseRenderPollingResult {
renderStatus: TaskStatus | null;
renderProgress: number;
error: string | null;
}
export const useRenderPolling = (
renderTaskId: string | null,
onSuccess?: () => void,
onError?: (error: string) => void
): UseRenderPollingResult => {
const [renderStatus, setRenderStatus] = useState<TaskStatus | null>(null);
const [renderProgress, setRenderProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (!renderTaskId) {
return;
}
// Start polling
const interval = setInterval(async () => {
try {
const status = await youtubeApi.getRenderStatus(renderTaskId);
setRenderStatus(status);
setRenderProgress(status.progress || 0);
// Stop polling if task is completed or failed
if (status.status === 'completed' || status.status === 'failed') {
console.log(`[YouTubeCreator] Task ${renderTaskId} finished with status: ${status.status}`);
// Clear interval immediately
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (status.status === 'completed') {
onSuccess?.();
} else if (status.status === 'failed') {
// Extract error message from status
const errorMessage = status.error ||
status.message ||
(typeof status.result === 'object' && status.result?.error) ||
'Video rendering failed. Please try again.';
setError(errorMessage);
onError?.(errorMessage);
console.error(`[YouTubeCreator] Render task failed:`, status);
}
return;
}
} catch (err: any) {
console.error('Failed to poll render status:', err);
// Handle 404 - task not found
const is404 = err.response?.status === 404 ||
err.message?.includes('Task not found') ||
err.response?.data?.detail?.error === 'Task not found';
if (is404) {
console.warn(`[YouTubeCreator] Task ${renderTaskId} not found, stopping polling`);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const errorDetail = err.response?.data?.detail;
const errorMessage = errorDetail?.message ||
errorDetail?.error ||
'Render task not found. This may happen if the server restarted or the task expired. Please try rendering again.';
setError(errorMessage);
onError?.(errorMessage);
return;
}
// For 500 errors (server errors), stop polling
const is500 = err.response?.status === 500;
if (is500) {
console.error(`[YouTubeCreator] Server error while polling, stopping`);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const errorMessage = 'Server error occurred while checking render status. Please try rendering again.';
setError(errorMessage);
onError?.(errorMessage);
return;
}
// For other errors, continue polling but log them
console.warn(`[YouTubeCreator] Polling error (non-critical), will retry:`, err.message);
}
}, POLLING_INTERVAL_MS);
intervalRef.current = interval;
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [renderTaskId, onSuccess, onError]);
return {
renderStatus,
renderProgress,
error,
};
};

View File

@@ -0,0 +1,38 @@
/**
* Shared styles for YouTube Creator Studio
*/
import { YT_RED, YT_TEXT } from './constants';
export const inputSx = {
'& .MuiOutlinedInput-root': {
backgroundColor: '#fff',
color: YT_TEXT,
borderRadius: 1,
'& fieldset': {
borderColor: '#c6c6c6',
},
'&:hover fieldset': {
borderColor: YT_RED,
},
'&.Mui-focused fieldset': {
borderColor: YT_RED,
boxShadow: '0 0 0 2px rgba(255,0,0,0.08)',
},
'& input::placeholder, & textarea::placeholder': {
color: '#5f6368',
opacity: 1,
},
},
};
export const selectSx = {
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#c6c6c6' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
'& .MuiSelect-select': { color: YT_TEXT, backgroundColor: '#fff' },
};
export const labelSx = { color: '#5f6368', '&.Mui-focused': { color: YT_RED } };
export const helperSx = { color: '#5f6368' };

View File

@@ -76,10 +76,12 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
buttonProps = {},
}) => {
const preflightOptions: UsePreflightCheckOptions = {
operation,
enabled: checkOnHover || checkOnMount,
debounceMs: 300,
cacheTtl: 5000,
onBlocked: (response) => {
// Handle blocked response if needed
},
onAllowed: (response) => {
// Handle allowed response if needed
},
};
const {
@@ -88,20 +90,19 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
limitInfo,
loading: preflightLoading,
error: preflightError,
checkOnHover: triggerCheckOnHover,
checkNow: triggerCheckNow,
check: triggerCheck,
} = usePreflightCheck(preflightOptions);
// Check on mount if requested
React.useEffect(() => {
if (checkOnMount) {
triggerCheckNow();
triggerCheck(operation);
}
}, [checkOnMount, triggerCheckNow]);
}, [checkOnMount, triggerCheck, operation]);
// Notify parent of pre-flight result changes
React.useEffect(() => {
if (onPreflightResult) {
if (onPreflightResult && canProceed !== null) {
onPreflightResult(canProceed);
}
}, [canProceed, onPreflightResult]);
@@ -129,7 +130,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
// Determine if button should be disabled
const isDisabled = useMemo(() => {
return externalDisabled || externalLoading || preflightLoading || !canProceed;
return externalDisabled || externalLoading || preflightLoading || (canProceed !== null && !canProceed);
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
// Build tooltip content
@@ -155,7 +156,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
content.push(
<Box key="limits" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{canProceed ? '✅ Operation Allowed' : '❌ Operation Blocked'}
{(canProceed === null || canProceed) ? '✅ Operation Allowed' : '❌ Operation Blocked'}
</Typography>
{isUnlimited ? (
<Typography variant="caption" sx={{ display: 'block' }}>
@@ -189,20 +190,20 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
// Handle hover
const handleMouseEnter = () => {
if (checkOnHover) {
triggerCheckOnHover();
triggerCheck(operation);
}
};
// Handle click
const handleClick = () => {
if (!isDisabled && canProceed) {
if (!isDisabled && (canProceed === null || canProceed)) {
onClick();
}
};
// Determine button color based on state
const buttonColor = useMemo(() => {
if (!canProceed) {
if (canProceed !== null && !canProceed) {
return 'error';
}
return color;
@@ -219,7 +220,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
if (showLoading && !externalLoading) {
return 'Checking...';
}
if (!canProceed && preflightError) {
if (canProceed !== null && !canProceed && preflightError) {
return preflightError;
}
return buttonLabel;
@@ -234,7 +235,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
startIcon={
showLoading ? (
<CircularProgress size={16} color="inherit" />
) : !canProceed ? (
) : (canProceed !== null && !canProceed) ? (
<WarningIcon fontSize="small" />
) : (
startIcon