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

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