WIP: AI Podcast Maker and YouTube Creator Studio integration
This commit is contained in:
157
frontend/src/components/PodcastMaker/AnalysisPanel.tsx
Normal file
157
frontend/src/components/PodcastMaker/AnalysisPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
333
frontend/src/components/PodcastMaker/CreateModal.tsx
Normal file
333
frontend/src/components/PodcastMaker/CreateModal.tsx
Normal 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 won’t auto-run anything).<br />
|
||||
• Keep it concise—one 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. We’ll 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 won’t 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 won’t 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" : "We’ll start AI analysis after this click"}
|
||||
>
|
||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
92
frontend/src/components/PodcastMaker/FactCard.tsx
Normal file
92
frontend/src/components/PodcastMaker/FactCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
118
frontend/src/components/PodcastMaker/InlineAudioPlayer.tsx
Normal file
118
frontend/src/components/PodcastMaker/InlineAudioPlayer.tsx
Normal 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
134
frontend/src/components/PodcastMaker/PreflightBlockDialog.tsx
Normal file
134
frontend/src/components/PodcastMaker/PreflightBlockDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
343
frontend/src/components/PodcastMaker/ProjectList.tsx
Normal file
343
frontend/src/components/PodcastMaker/ProjectList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
552
frontend/src/components/PodcastMaker/RenderQueue.tsx
Normal file
552
frontend/src/components/PodcastMaker/RenderQueue.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
106
frontend/src/components/PodcastMaker/ScriptEditor/LineEditor.tsx
Normal file
106
frontend/src/components/PodcastMaker/ScriptEditor/LineEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { LineEditor } from "./LineEditor";
|
||||
export { SceneEditor } from "./SceneEditor";
|
||||
export { ScriptEditor } from "./ScriptEditor";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
14
frontend/src/components/PodcastMaker/ui/GlassyCard.tsx
Normal file
14
frontend/src/components/PodcastMaker/ui/GlassyCard.tsx
Normal 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)",
|
||||
};
|
||||
|
||||
58
frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx
Normal file
58
frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
|
||||
52
frontend/src/components/PodcastMaker/ui/SecondaryButton.tsx
Normal file
52
frontend/src/components/PodcastMaker/ui/SecondaryButton.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
|
||||
4
frontend/src/components/PodcastMaker/ui/index.ts
Normal file
4
frontend/src/components/PodcastMaker/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { GlassyCard, glassyCardSx } from "./GlassyCard";
|
||||
export { PrimaryButton } from "./PrimaryButton";
|
||||
export { SecondaryButton } from "./SecondaryButton";
|
||||
|
||||
Reference in New Issue
Block a user