Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
@@ -1,25 +1,142 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "./types";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Tooltip, Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, Delete as DeleteIcon, EditNote as EditNoteIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis, PodcastEstimate } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { aiApiClient } from "../../api/client";
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: PodcastAnalysis | null;
|
||||
estimate: PodcastEstimate | null;
|
||||
idea?: string;
|
||||
duration?: number;
|
||||
speakers?: number;
|
||||
avatarUrl?: string | null;
|
||||
avatarPrompt?: string | null;
|
||||
onRegenerate?: () => void;
|
||||
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
|
||||
}
|
||||
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, duration, speakers, avatarUrl, avatarPrompt, onRegenerate }) => {
|
||||
const inputStyles = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#111827 !important',
|
||||
fontWeight: 500,
|
||||
WebkitTextFillColor: '#111827 !important', // Fix for some browsers
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#4b5563 !important',
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: '#ffffff !important',
|
||||
'& fieldset': {
|
||||
borderColor: '#d1d5db !important',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#4f46e5 !important',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#4f46e5 !important',
|
||||
}
|
||||
},
|
||||
'& .MuiSelect-select': {
|
||||
color: '#111827 !important',
|
||||
WebkitTextFillColor: '#111827 !important',
|
||||
}
|
||||
};
|
||||
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
|
||||
analysis,
|
||||
estimate,
|
||||
idea,
|
||||
duration,
|
||||
speakers,
|
||||
avatarUrl,
|
||||
avatarPrompt,
|
||||
onRegenerate,
|
||||
onUpdateAnalysis
|
||||
}) => {
|
||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
// Edit states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
|
||||
|
||||
// Sync editedAnalysis with analysis initially
|
||||
useEffect(() => {
|
||||
if (analysis && !editedAnalysis) {
|
||||
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
|
||||
}
|
||||
}, [analysis]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editedAnalysis && onUpdateAnalysis) {
|
||||
console.log('[AnalysisPanel] Saving updated analysis:', editedAnalysis);
|
||||
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
|
||||
};
|
||||
|
||||
const updateExaConfig = (field: string, value: any) => {
|
||||
if (!editedAnalysis) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
exaSuggestedConfig: {
|
||||
...(editedAnalysis.exaSuggestedConfig || {}),
|
||||
[field]: value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddKeyword = (keyword: string) => {
|
||||
if (!editedAnalysis || !keyword.trim()) return;
|
||||
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveKeyword = (keyword: string) => {
|
||||
if (!editedAnalysis) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddTitle = (title: string) => {
|
||||
if (!editedAnalysis || !title.trim()) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveTitle = (title: string) => {
|
||||
if (!editedAnalysis) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
|
||||
if (!editedAnalysis) return;
|
||||
setEditedAnalysis({
|
||||
...editedAnalysis,
|
||||
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
|
||||
o.id === id ? { ...o, [field]: value } : o
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
// Load avatar image as blob for authenticated URLs
|
||||
useEffect(() => {
|
||||
@@ -93,44 +210,117 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
|
||||
}, [avatarUrl]);
|
||||
|
||||
if (!analysis) return null;
|
||||
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
|
||||
|
||||
console.log('[AnalysisPanel] Rendering:', { isEditing, hasEditedAnalysis: !!editedAnalysis });
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28 }}
|
||||
className="light-theme-container"
|
||||
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",
|
||||
color: "#111827",
|
||||
}}
|
||||
aria-label="analysis-panel"
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" alignItems="center" spacing={2} flex={1}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
color: "#1e293b",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
<PsychologyIcon />
|
||||
<PsychologyIcon sx={{ color: "#4f46e5" }} />
|
||||
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>
|
||||
|
||||
{estimate && (
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2, flex: 1, overflow: 'hidden' }}>
|
||||
<Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
|
||||
Est. Cost: ${estimate.total.toFixed(2)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Research: $${estimate.researchCost.toFixed(2)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<SecondaryButton
|
||||
onClick={handleSave}
|
||||
startIcon={<SaveIcon />}
|
||||
sx={{
|
||||
color: '#059669',
|
||||
borderColor: '#10b981',
|
||||
bgcolor: 'white',
|
||||
fontWeight: 600,
|
||||
'&:hover': { bgcolor: alpha('#10b981', 0.05) }
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={handleCancel}
|
||||
startIcon={<CloseIcon />}
|
||||
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SecondaryButton
|
||||
onClick={() => setIsEditing(true)}
|
||||
startIcon={<EditIcon />}
|
||||
sx={{ color: '#4f46e5', borderColor: '#4f46e5', bgcolor: 'white', fontWeight: 600 }}
|
||||
>
|
||||
Edit Analysis
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={onRegenerate}
|
||||
startIcon={<RefreshIcon />}
|
||||
tooltip="Regenerate analysis with different parameters"
|
||||
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
|
||||
>
|
||||
Regenerate
|
||||
</SecondaryButton>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
@@ -359,31 +549,56 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={3}>
|
||||
<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>
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
size="small"
|
||||
value={currentAnalysis.audience}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, audience: e.target.value })}
|
||||
placeholder="Describe your target audience..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{currentAnalysis.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)" }} />
|
||||
{isEditing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={currentAnalysis.contentType}
|
||||
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, contentType: e.target.value })}
|
||||
placeholder="e.g. Interview, Narrative, Solo..."
|
||||
sx={inputStyles}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={currentAnalysis.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) => (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.topKeywords.map((k) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={isEditing ? () => handleRemoveKeyword(k) : undefined}
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
@@ -392,120 +607,291 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add keyword and press Enter..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddKeyword((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddKeyword(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#111827", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Suggested Episode Outlines
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{currentAnalysis.suggestedOutlines.map((o) => (
|
||||
<Paper
|
||||
key={o.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
background: isEditing ? "#ffffff" : "#f8fafc",
|
||||
border: "1px solid",
|
||||
borderColor: isEditing ? "#e2e8f0" : "rgba(0,0,0,0.04)",
|
||||
borderRadius: 2,
|
||||
wordBreak: "break-word",
|
||||
position: 'relative',
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
borderColor: "#4f46e5",
|
||||
boxShadow: "0 4px 12px rgba(79, 70, 229, 0.05)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Outline Title"
|
||||
value={o.title}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'title', e.target.value)}
|
||||
sx={inputStyles}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
size="small"
|
||||
label="Segments"
|
||||
value={o.segments.join(' • ')}
|
||||
onChange={(e) => handleUpdateOutline(o.id, 'segments', e.target.value.split(/•|,/).map(s => s.trim()).filter(Boolean))}
|
||||
helperText="Use • or comma to separate segments"
|
||||
sx={inputStyles}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body1" sx={{ fontWeight: 800, mb: 1, color: "#111827" }}>
|
||||
{o.title}
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{o.segments.map((segment, idx) => (
|
||||
<Box key={idx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box sx={{ mt: 1, width: 6, height: 6, borderRadius: "50%", bgcolor: "#4f46e5", flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: "#4b5563", lineHeight: 1.5 }}>
|
||||
{segment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{analysis.exaSuggestedConfig && (
|
||||
<Stack spacing={3}>
|
||||
{currentAnalysis.exaSuggestedConfig && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Exa Research Suggestions
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{analysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip
|
||||
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip
|
||||
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.date_range && (
|
||||
<Chip
|
||||
label={`Date: ${analysis.exaSuggestedConfig.date_range}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && (
|
||||
<Chip
|
||||
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip
|
||||
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{isEditing ? (
|
||||
<Stack spacing={2} sx={{ p: 2, border: '1px solid #e2e8f0', borderRadius: 2, bgcolor: '#ffffff' }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Search Type</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_search_type || 'auto'}
|
||||
label="Search Type"
|
||||
onChange={(e) => updateExaConfig('exa_search_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">Auto</MenuItem>
|
||||
<MenuItem value="neural">Neural</MenuItem>
|
||||
<MenuItem value="keyword">Keyword</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Category</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.exa_category || 'news'}
|
||||
label="Category"
|
||||
onChange={(e) => updateExaConfig('exa_category', e.target.value)}
|
||||
>
|
||||
<MenuItem value="news">News</MenuItem>
|
||||
<MenuItem value="research paper">Research Paper</MenuItem>
|
||||
<MenuItem value="company">Company</MenuItem>
|
||||
<MenuItem value="pdf">PDF</MenuItem>
|
||||
<MenuItem value="tweet">Tweet</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_include_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Prefer domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_include_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl fullWidth size="small" sx={inputStyles}>
|
||||
<InputLabel>Date Range</InputLabel>
|
||||
<Select
|
||||
value={currentAnalysis.exaSuggestedConfig.date_range || 'all_time'}
|
||||
label="Date Range"
|
||||
onChange={(e) => updateExaConfig('date_range', e.target.value)}
|
||||
>
|
||||
<MenuItem value="all_time">All Time</MenuItem>
|
||||
<MenuItem value="last_month">Last Month</MenuItem>
|
||||
<MenuItem value="last_year">Last Year</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Max Sources"
|
||||
size="small"
|
||||
value={currentAnalysis.exaSuggestedConfig.max_sources || 10}
|
||||
onChange={(e) => updateExaConfig('max_sources', parseInt(e.target.value))}
|
||||
sx={{ ...inputStyles, width: 120 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{analysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Avoid domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={currentAnalysis.exaSuggestedConfig.include_statistics || false}
|
||||
onChange={(e) => updateExaConfig('include_statistics', e.target.checked)}
|
||||
sx={{ '& .MuiSwitch-track': { bgcolor: '#4f46e5' } }}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2" sx={{ color: '#111827', fontWeight: 500 }}>Include Statistics</Typography>}
|
||||
/>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Prefer Domains"
|
||||
placeholder="e.g. techcrunch.com, wired.com (press Enter)"
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains || [];
|
||||
updateExaConfig('exa_include_domains', [...domains, val]);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains || []).map(d => (
|
||||
<Chip key={d} label={d} size="small" onDelete={() => {
|
||||
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains?.filter(item => item !== d);
|
||||
updateExaConfig('exa_include_domains', domains);
|
||||
}} sx={{ bgcolor: '#f3f4f6', color: '#111827' }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip
|
||||
label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip
|
||||
label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.date_range && (
|
||||
<Chip
|
||||
label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{typeof currentAnalysis.exaSuggestedConfig.include_statistics === "boolean" && (
|
||||
<Chip
|
||||
label={currentAnalysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{currentAnalysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip
|
||||
label={`Max sources: ${currentAnalysis.exaSuggestedConfig.max_sources}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(currentAnalysis.exaSuggestedConfig.exa_include_domains?.length || currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Prefer domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_include_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Avoid domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<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) => (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
|
||||
{currentAnalysis.titleSuggestions.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
onDelete={isEditing ? () => handleRemoveTitle(t) : undefined}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
cursor: isEditing ? "default" : "pointer",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
@@ -519,7 +905,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
|
||||
paddingTop: 0.25,
|
||||
paddingBottom: 0.25,
|
||||
},
|
||||
"&:hover": {
|
||||
"&:hover": isEditing ? {} : {
|
||||
background: alpha("#667eea", 0.15),
|
||||
border: "1px solid rgba(102,126,234,0.35)",
|
||||
},
|
||||
@@ -527,6 +913,32 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{isEditing && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Add title suggestion..."
|
||||
sx={inputStyles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTitle((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
|
||||
handleAddTitle(input.value);
|
||||
input.value = '';
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
306
frontend/src/components/PodcastMaker/AvatarAssetBrowser.tsx
Normal file
306
frontend/src/components/PodcastMaker/AvatarAssetBrowser.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
Button,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Collections as CollectionsIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Favorite as FavoriteIcon,
|
||||
FavoriteBorder as FavoriteBorderIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentAssets } from '../../hooks/useContentAssets';
|
||||
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
|
||||
|
||||
interface AvatarAssetBrowserProps {
|
||||
onSelect: (url: string) => void;
|
||||
selectedUrl: string | null;
|
||||
}
|
||||
|
||||
export const AvatarAssetBrowser: React.FC<AvatarAssetBrowserProps> = ({ onSelect, selectedUrl }) => {
|
||||
const [filter, setFilter] = useState<'all' | 'favorites'>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
|
||||
const [limit, setLimit] = useState(24);
|
||||
|
||||
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets({
|
||||
asset_type: 'image',
|
||||
search: search || undefined,
|
||||
favorites_only: filter === 'favorites',
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
// No-op useEffect to satisfy the linter if needed, but the actual fetch is handled by useContentAssets hook's internal useEffect
|
||||
// which runs when stableFilters change.
|
||||
// The user reported that images don't load on initial tab mount unless toggled.
|
||||
// useContentAssets's useEffect(fetchAssets, [filterKey, fetchAssets]) should handle it,
|
||||
// but if it's failing initially due to auth timing, this manual refetch helps.
|
||||
useEffect(() => {
|
||||
// Only refetch on mount to ensure initial load
|
||||
const timer = setTimeout(() => {
|
||||
refetch();
|
||||
}, 200); // Slightly longer delay to ensure auth is fully ready
|
||||
return () => clearTimeout(timer);
|
||||
}, [refetch]); // Only run on mount or if refetch function changes
|
||||
|
||||
// Check if a URL requires authentication (internal API endpoints)
|
||||
const isAuthenticatedUrl = React.useCallback((url: string): boolean => {
|
||||
if (!url) return false;
|
||||
return url.includes('/api/podcast/') ||
|
||||
url.includes('/api/youtube/') ||
|
||||
url.includes('/api/story/') ||
|
||||
(url.startsWith('/') && !url.startsWith('//'));
|
||||
}, []);
|
||||
|
||||
// Load blob URLs for authenticated images
|
||||
useEffect(() => {
|
||||
if (assets.length === 0) {
|
||||
setImageBlobUrls(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
const loadBlobUrls = async () => {
|
||||
const newBlobUrls = new Map<number, string>();
|
||||
const newLoadingImages = new Set<number>();
|
||||
|
||||
for (const asset of assets) {
|
||||
if (!asset.file_url) continue;
|
||||
|
||||
if (isAuthenticatedUrl(asset.file_url)) {
|
||||
newLoadingImages.add(asset.id);
|
||||
try {
|
||||
const blobUrl = await fetchMediaBlobUrl(asset.file_url);
|
||||
if (blobUrl) {
|
||||
newBlobUrls.set(asset.id, blobUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load image for asset ${asset.id}:`, err);
|
||||
} finally {
|
||||
newLoadingImages.delete(asset.id);
|
||||
}
|
||||
} else {
|
||||
newBlobUrls.set(asset.id, asset.file_url);
|
||||
}
|
||||
}
|
||||
|
||||
setImageBlobUrls(prev => {
|
||||
// Revoke old blobs that are no longer needed
|
||||
prev.forEach((url, id) => {
|
||||
if (url.startsWith('blob:') && !newBlobUrls.has(id)) URL.revokeObjectURL(url);
|
||||
});
|
||||
return newBlobUrls;
|
||||
});
|
||||
setLoadingImages(newLoadingImages);
|
||||
};
|
||||
|
||||
loadBlobUrls();
|
||||
|
||||
// Cleanup on unmount/change is handled by the effect below or next run
|
||||
}, [assets, isAuthenticatedUrl]);
|
||||
|
||||
// Cleanup all blobs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
imageBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setLimit(prev => prev + 24);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%' }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1, width: '100%' }}>
|
||||
<TextField
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
bgcolor: 'white',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
'&:hover fieldset': { borderColor: '#cbd5e1' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#667eea' },
|
||||
'& .MuiOutlinedInput-input': {
|
||||
color: '#0f172a',
|
||||
py: 1,
|
||||
'&::placeholder': {
|
||||
color: '#94a3b8',
|
||||
opacity: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
placeholder="Search images..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" sx={{ color: '#64748b' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
row
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'favorites')}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
ml: 0.5,
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
'& .MuiFormControlLabel-root': {
|
||||
mr: 0.5,
|
||||
ml: 0,
|
||||
'& .MuiTypography-root': {
|
||||
color: '#334155',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
'& .MuiRadio-root': {
|
||||
p: 0.5,
|
||||
color: '#94a3b8',
|
||||
'&.Mui-checked': {
|
||||
color: '#667eea',
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="all"
|
||||
control={<Radio size="small" />}
|
||||
label="All"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="favorites"
|
||||
control={<Radio size="small" />}
|
||||
label="Favs"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Stack>
|
||||
|
||||
{loading && assets.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : assets.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', p: 4, bgcolor: '#f8fafc', borderRadius: 2 }}>
|
||||
<CollectionsIcon sx={{ fontSize: 48, color: '#cbd5e1', mb: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{search ? 'No matches found' : 'No images in library'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={1.5} sx={{ maxHeight: 300, overflowY: 'auto', pr: 0.5 }}>
|
||||
{assets.map((asset) => (
|
||||
<Grid item xs={6} sm={4} key={asset.id}>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
border: selectedUrl === asset.file_url ? '2px solid #667eea' : '1px solid #e2e8f0',
|
||||
'&:hover': { borderColor: '#667eea' }
|
||||
}}
|
||||
onClick={() => asset.file_url && onSelect(asset.file_url)}
|
||||
>
|
||||
<Box sx={{ position: 'relative', paddingTop: '100%' }}>
|
||||
{isAuthenticatedUrl(asset.file_url) && !imageBlobUrls.has(asset.id) ? (
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f8fafc' }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={imageBlobUrls.get(asset.id) || asset.file_url || ''}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loadingImages.has(asset.id) && (
|
||||
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(255,255,255,0.7)' }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: 'rgba(255,255,255,0.8)',
|
||||
'&:hover': { bgcolor: 'white' },
|
||||
width: 24,
|
||||
height: 24,
|
||||
p: 0.5
|
||||
}}
|
||||
>
|
||||
{asset.is_favorite ? <FavoriteIcon fontSize="small" color="error" /> : <FavoriteBorderIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
{selectedUrl === asset.file_url && (
|
||||
<Box sx={{ bgcolor: '#667eea', borderRadius: '50%', p: 0.5, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24 }}>
|
||||
<CheckCircleIcon sx={{ color: 'white', fontSize: 16 }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{/* Load More Button */}
|
||||
{total > limit && (
|
||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2, pb: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : <ExpandMoreIcon />}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load More'}
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Tabs, Tab, CircularProgress, Button, IconButton, Tooltip, alpha } from "@mui/material";
|
||||
import {
|
||||
Person as PersonIcon,
|
||||
Info as InfoIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Collections as CollectionsIcon,
|
||||
Delete as DeleteIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
CloudUpload as CloudUploadIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
|
||||
import { SecondaryButton } from "../ui";
|
||||
|
||||
interface AvatarSelectorProps {
|
||||
avatarTab: number;
|
||||
setAvatarTab: (event: React.SyntheticEvent, newValue: number) => void;
|
||||
avatarFile: File | null;
|
||||
avatarPreview: string | null;
|
||||
avatarUrl: string | null;
|
||||
loadingBrandAvatar: boolean;
|
||||
handleUseBrandAvatar: () => void;
|
||||
handleAvatarSelectFromLibrary: (url: string) => void;
|
||||
handleAvatarChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleRemoveAvatar: () => void;
|
||||
handleMakePresentable: () => void;
|
||||
makingPresentable: boolean;
|
||||
avatarPreviewBlobUrl: string | null;
|
||||
brandAvatarFromDb?: string | null;
|
||||
brandAvatarBlobUrl?: string | null;
|
||||
}
|
||||
|
||||
export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
|
||||
avatarTab,
|
||||
setAvatarTab,
|
||||
avatarFile,
|
||||
avatarPreview,
|
||||
avatarUrl,
|
||||
loadingBrandAvatar,
|
||||
handleUseBrandAvatar,
|
||||
handleAvatarSelectFromLibrary,
|
||||
handleAvatarChange,
|
||||
handleRemoveAvatar,
|
||||
handleMakePresentable,
|
||||
makingPresentable,
|
||||
avatarPreviewBlobUrl,
|
||||
brandAvatarFromDb,
|
||||
brandAvatarBlobUrl,
|
||||
}) => {
|
||||
const isAuthenticatedUrl = React.useCallback((url: string | null): boolean => {
|
||||
if (!url) return false;
|
||||
return url.includes('/api/podcast/') ||
|
||||
url.includes('/api/youtube/') ||
|
||||
url.includes('/api/story/') ||
|
||||
(url.startsWith('/') && !url.startsWith('//'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Podcast Presenter Avatar
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Avatar Options:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI.<br/><br/>
|
||||
<strong>Brand Avatar:</strong> Use your configured brand avatar for consistency.<br/><br/>
|
||||
<strong>Asset Library:</strong> Choose from your previously uploaded images.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
|
||||
{/* Left Side: Tabs & Content */}
|
||||
<Box sx={{ flex: 1, width: "100%" }}>
|
||||
<Tabs
|
||||
value={avatarTab}
|
||||
onChange={setAvatarTab}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
mb: 3,
|
||||
minHeight: 48,
|
||||
"& .MuiTabs-indicator": {
|
||||
display: "none",
|
||||
},
|
||||
"& .MuiTabs-flexContainer": {
|
||||
gap: 1.5,
|
||||
},
|
||||
"& .MuiTab-root": {
|
||||
textTransform: "none",
|
||||
minHeight: 44,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
borderRadius: "12px",
|
||||
px: 2.5,
|
||||
color: "#64748b",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
backgroundColor: "#ffffff",
|
||||
"&:hover": {
|
||||
borderColor: "#cbd5e1",
|
||||
backgroundColor: "#f8fafc",
|
||||
transform: "translateY(-1px)",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
color: "#ffffff",
|
||||
borderColor: "transparent",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab label="Use Brand Avatar" />
|
||||
<Tab label="Asset Library" />
|
||||
<Tab label="Upload Your Photo" />
|
||||
</Tabs>
|
||||
|
||||
{avatarTab === 0 && (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ minHeight: 200, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", bgcolor: "#f8fafc", borderRadius: 2, p: 2, position: "relative" }}>
|
||||
{loadingBrandAvatar ? (
|
||||
<CircularProgress size={32} />
|
||||
) : avatarPreview && avatarPreview === brandAvatarFromDb ? (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarPreviewBlobUrl || ""}
|
||||
alt="Selected Brand Avatar"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
border: "2px solid #667eea",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemoveAvatar}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
bgcolor: "white",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
|
||||
"&:hover": {
|
||||
bgcolor: "#fef2f2",
|
||||
borderColor: "#ef4444",
|
||||
color: "#ef4444",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<CheckCircleIcon color="primary" fontSize="small" />
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontStyle: "italic" }}>
|
||||
Active Presenter
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : brandAvatarFromDb ? (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Box
|
||||
component="img"
|
||||
src={brandAvatarBlobUrl || ""}
|
||||
alt="Available Brand Avatar"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
border: "1.5px solid #e2e8f0",
|
||||
opacity: 0.8,
|
||||
filter: "grayscale(0.3)",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleUseBrandAvatar}
|
||||
startIcon={<CheckCircleIcon />}
|
||||
sx={{
|
||||
borderRadius: "8px",
|
||||
textTransform: "none",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
}}
|
||||
>
|
||||
Use this Avatar
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<PersonIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No brand avatar found.
|
||||
</Typography>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void handleUseBrandAvatar()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea" }} />
|
||||
Brand Avatar
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
|
||||
Select your pre-configured brand avatar to maintain consistency. If not selected, a new AI presenter will be generated.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{avatarTab === 1 && (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ minHeight: 300, position: "relative" }}>
|
||||
{avatarPreview && !avatarFile && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemoveAvatar}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
bgcolor: "white",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
|
||||
"&:hover": {
|
||||
bgcolor: "#fef2f2",
|
||||
borderColor: "#ef4444",
|
||||
color: "#ef4444",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<AvatarAssetBrowser
|
||||
selectedUrl={avatarUrl}
|
||||
onSelect={(url) => handleAvatarSelectFromLibrary(url)}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
border: "1px solid rgba(15, 23, 42, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<CollectionsIcon fontSize="small" sx={{ color: "#64748b" }} />
|
||||
Asset Library
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
|
||||
Select from your previously uploaded images. Filter by favorites or search to find the perfect presenter.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{avatarTab === 2 && (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
{avatarFile && avatarPreview ? (
|
||||
<Stack spacing={2} alignItems="center" sx={{ bgcolor: "#f8fafc", borderRadius: 2, p: 2 }}>
|
||||
<Box sx={{ position: "relative", display: "inline-block" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={avatarPreviewBlobUrl || (avatarPreview.startsWith("data:") ? avatarPreview : "")}
|
||||
alt="Avatar preview"
|
||||
sx={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2.5,
|
||||
border: "2px solid #e2e8f0",
|
||||
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRemoveAvatar}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
bgcolor: "white",
|
||||
border: "1.5px solid #e2e8f0",
|
||||
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
|
||||
"&:hover": {
|
||||
bgcolor: "#f8fafc",
|
||||
borderColor: "#dc2626",
|
||||
color: "#dc2626",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{avatarUrl && (
|
||||
<Tooltip
|
||||
title="Transform your uploaded photo into a professional podcast presenter."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box>
|
||||
<SecondaryButton
|
||||
onClick={handleMakePresentable}
|
||||
disabled={makingPresentable}
|
||||
loading={makingPresentable}
|
||||
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : undefined}
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
{makingPresentable ? "Transforming..." : "Make Presentable"}
|
||||
</SecondaryButton>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box
|
||||
component="label"
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
minHeight: 200,
|
||||
border: "2px dashed #cbd5e1",
|
||||
borderRadius: 2.5,
|
||||
bgcolor: "#f8fafc",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
borderColor: "#667eea",
|
||||
bgcolor: "#f1f5f9",
|
||||
borderWidth: "2.5px",
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<CloudUploadIcon sx={{ color: "#94a3b8", fontSize: 36, mb: 1.5 }} />
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600, mb: 0.5 }}>
|
||||
Upload Your Photo
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
|
||||
JPG, PNG, WebP (max 5MB)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
border: "1px solid rgba(15, 23, 42, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<CloudUploadIcon fontSize="small" sx={{ color: "#64748b" }} />
|
||||
Upload Your Photo
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
|
||||
Upload a new photo and use <strong>"Make Presentable"</strong> to enhance it into a professional presenter using AI.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
background: alpha("#f0f4ff", 0.5),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#6366f1", fontSize: "0.8125rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<InfoIcon fontSize="inherit" />
|
||||
Supported formats: JPG, PNG, WebP (max 5MB)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Stack, Alert, Typography, alpha } from "@mui/material";
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Refresh as RefreshIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
interface CreateActionsProps {
|
||||
reset: () => void;
|
||||
submit: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const CreateActions: React.FC<CreateActionsProps> = ({
|
||||
reset,
|
||||
submit,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3.5}>
|
||||
{/* Info Banner */}
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
sx={{
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
"& .MuiAlert-message": {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
Podcast avatar Image is required, brand avatar is Default, you can choose existing images from asset library Or Upload your Picture. If not, AI Avatar will be generated automatically.
|
||||
</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>
|
||||
);
|
||||
};
|
||||
227
frontend/src/components/PodcastMaker/CreateStep/CreateHeader.tsx
Normal file
227
frontend/src/components/PodcastMaker/CreateStep/CreateHeader.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box, Typography, Tooltip, IconButton, Chip, alpha } from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { Knobs } from '../types';
|
||||
|
||||
interface CreateHeaderProps {
|
||||
subscription: any;
|
||||
duration: number;
|
||||
speakers: number;
|
||||
knobs: Knobs;
|
||||
estimatedCost: {
|
||||
ttsCost: number;
|
||||
avatarCost: number;
|
||||
videoCost: number;
|
||||
researchCost: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const CreateHeader: React.FC<CreateHeaderProps> = ({
|
||||
subscription,
|
||||
duration,
|
||||
speakers,
|
||||
knobs,
|
||||
estimatedCost,
|
||||
}) => {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.75rem" }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: "1.5rem", md: "1.75rem" },
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Create New Podcast Episode
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Tips for best results:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem" }}>
|
||||
• 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>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 300,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": {
|
||||
color: "#667eea",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
|
||||
<Tooltip
|
||||
title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`Plan: ${subscription?.tier || "free"}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={`Podcast duration: ${duration} minutes. Maximum duration is 10 minutes. Recommended: 5-10 minutes for best results.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`Duration: ${duration} min`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#0f172a", 0.06),
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={`Number of speakers: ${speakers}. Supports 1-2 speakers. Each additional speaker adds avatar generation cost.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`${speakers} speaker${speakers > 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#0f172a", 0.06),
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Estimated Cost Breakdown:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
• Audio Generation: ${estimatedCost.ttsCost}<br />
|
||||
• Avatar Creation: ${estimatedCost.avatarCost}<br />
|
||||
• Video Rendering: ${estimatedCost.videoCost}<br />
|
||||
• Research: ${estimatedCost.researchCost}<br />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
|
||||
Total: ${estimatedCost.total}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
|
||||
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs.bitrate === "hd" ? "HD" : "standard"} quality
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 280,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`Est. $${estimatedCost.total}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
|
||||
color: "#059669",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, TextField, ToggleButton, ToggleButtonGroup, alpha } from "@mui/material";
|
||||
import { Person as PersonIcon, Group as GroupIcon } from "@mui/icons-material";
|
||||
|
||||
interface PodcastConfigurationProps {
|
||||
duration: number;
|
||||
setDuration: (value: number) => void;
|
||||
speakers: number;
|
||||
setSpeakers: (value: number) => void;
|
||||
}
|
||||
|
||||
export const PodcastConfiguration: React.FC<PodcastConfigurationProps> = ({
|
||||
duration,
|
||||
setDuration,
|
||||
speakers,
|
||||
setSpeakers,
|
||||
}) => {
|
||||
const handleDurationChange = (value: number) => {
|
||||
const clamped = Math.min(10, Math.max(1, value));
|
||||
setDuration(clamped);
|
||||
};
|
||||
|
||||
const handleSpeakersChange = (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
newValue: number | null
|
||||
) => {
|
||||
if (newValue !== null) {
|
||||
setSpeakers(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: { xs: "1 1 auto", lg: "0 0 320px" },
|
||||
width: { xs: "100%", lg: "320px" },
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2.5, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Basic Configuration
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Duration Input */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 500 }}>
|
||||
Duration (minutes)
|
||||
</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 10 } }}
|
||||
size="small"
|
||||
helperText={duration > 10 ? "Maximum duration is 10 minutes" : "Recommended: 1-3 mins"}
|
||||
error={duration > 10}
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.6)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
borderColor: "#667eea",
|
||||
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.1)",
|
||||
},
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: duration > 10 ? "#dc2626" : "#64748b",
|
||||
fontSize: "0.75rem",
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Speakers Toggle */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: "block", mb: 1, color: "#64748b", fontWeight: 500 }}>
|
||||
Number of Speakers
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={speakers}
|
||||
exclusive
|
||||
onChange={handleSpeakersChange}
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
p: 0.5,
|
||||
"& .MuiToggleButton-root": {
|
||||
border: "none",
|
||||
borderRadius: 1.5,
|
||||
color: "#64748b",
|
||||
textTransform: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
py: 1,
|
||||
transition: "all 0.2s ease",
|
||||
"&:hover": {
|
||||
backgroundColor: alpha("#64748b", 0.05),
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: alpha("#667eea", 0.1),
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
"&:hover": {
|
||||
backgroundColor: alpha("#667eea", 0.15),
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={1} aria-label="1 speaker">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PersonIcon fontSize="small" />
|
||||
<Typography variant="body2">1 Speaker</Typography>
|
||||
</Stack>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={2} aria-label="2 speakers">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupIcon fontSize="small" />
|
||||
<Typography variant="body2">2 Speakers</Typography>
|
||||
</Stack>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Typography variant="caption" sx={{ display: "block", mt: 0.75, color: "#64748b", fontSize: "0.75rem" }}>
|
||||
{speakers === 1 ? "Single host format" : "Host and guest conversation"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, TextField, Tooltip, Button, alpha } from "@mui/material";
|
||||
import { AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
|
||||
export const TOPIC_PLACEHOLDERS = [
|
||||
"Industry insights: Latest trends in AI for Content Marketing",
|
||||
"Product deep-dive: How our new feature solves common pain points",
|
||||
"Educational: 5 ways to improve your workflow with automation",
|
||||
"Thought leadership: The future of decentralized finance (DeFi)",
|
||||
"Interview prep: Key questions for your next tech hiring round",
|
||||
"Podcast prep: Analyzing the impact of remote work on mental health",
|
||||
];
|
||||
|
||||
interface TopicUrlInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
isUrl: boolean;
|
||||
showAIDetailsButton: boolean;
|
||||
onAIDetailsClick?: () => void;
|
||||
placeholderIndex: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
isUrl,
|
||||
showAIDetailsButton,
|
||||
onAIDetailsClick,
|
||||
placeholderIndex,
|
||||
loading = false,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
height: "100%", // Fill height of parent
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box flex={1} display="flex" flexDirection="column">
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Topic Idea or Blog URL
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
isUrl
|
||||
? "We detected a URL. We'll fetch insights from this page."
|
||||
: "Enter a concise idea or paste a blog URL."
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
placeholder={!value ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}" or paste a URL` : ""}
|
||||
inputProps={{
|
||||
sx: {
|
||||
"&::placeholder": { color: "#94a3b8", opacity: 1 },
|
||||
color: "#0f172a",
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="small"
|
||||
helperText={
|
||||
isUrl
|
||||
? "URL detected. We'll analyze this page content."
|
||||
: "Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
|
||||
}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: isUrl ? "#10b981" : "#667eea", // Green for URL, Blue for Topic
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
fontSize: "0.9375rem",
|
||||
lineHeight: 1.6,
|
||||
color: "#0f172a",
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: isUrl ? "#059669" : "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 400,
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add details with AI button - appears when user types (and not a URL) */}
|
||||
{showAIDetailsButton && !isUrl && (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={onAIDetailsClick}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 1.5,
|
||||
color: "#667eea",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
borderColor: "#5568d3",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? "Enhancing..." : "Add details with AI"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -48,6 +48,23 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1} sx={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Source Image */}
|
||||
{fact.image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={fact.image}
|
||||
alt={fact.url}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 120,
|
||||
objectFit: "cover",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.04)"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quote Text - Truncated with expand option */}
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<Typography
|
||||
@@ -66,6 +83,21 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
>
|
||||
{expanded ? fullText : previewText}
|
||||
</Typography>
|
||||
|
||||
{/* Highlights */}
|
||||
{fact.highlights && fact.highlights.length > 0 && expanded && (
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px dashed rgba(0,0,0,0.06)" }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: "#64748b", mb: 0.5, display: "block" }}>
|
||||
Highlights:
|
||||
</Typography>
|
||||
{fact.highlights.slice(0, 2).map((highlight, idx) => (
|
||||
<Typography key={idx} variant="caption" sx={{ display: "block", color: "#475569", mb: 0.5, fontStyle: "italic" }}>
|
||||
"{highlight}"
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{shouldTruncate && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -162,7 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const effectiveAudioUrl = blobUrl || audioUrl;
|
||||
const isPodcastAudio = audioUrl.includes('/api/podcast/audio/') || audioUrl.includes('/api/story/audio/');
|
||||
const effectiveAudioUrl = blobUrl || (!isPodcastAudio ? audioUrl : null);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
|
||||
186
frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx
Normal file
186
frontend/src/components/PodcastMaker/PodcastBiblePanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
AutoFixHigh as AutoFixHighIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Groups as GroupsIcon,
|
||||
BrandingWatermark as BrandIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface PodcastBiblePanelProps {
|
||||
bible: any;
|
||||
onUpdate: (updatedBible: any) => void;
|
||||
}
|
||||
|
||||
export const PodcastBiblePanel: React.FC<PodcastBiblePanelProps> = ({ bible, onUpdate }) => {
|
||||
if (!bible) return null;
|
||||
|
||||
const handleUpdateHost = (field: string, value: any) => {
|
||||
onUpdate({
|
||||
...bible,
|
||||
host: { ...bible.host, [field]: value }
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateAudience = (field: string, value: any) => {
|
||||
onUpdate({
|
||||
...bible,
|
||||
audience: { ...bible.audience, [field]: value }
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateBrand = (field: string, value: any) => {
|
||||
onUpdate({
|
||||
...bible,
|
||||
brand: { ...bible.brand, [field]: value }
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
|
||||
<AutoFixHighIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight="bold" color="#1e293b">
|
||||
Podcast Bible
|
||||
</Typography>
|
||||
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
|
||||
<IconButton size="small">
|
||||
<InfoIcon fontSize="small" sx={{ color: '#94a3b8' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{/* Host Persona */}
|
||||
<Accordion defaultExpanded sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<PsychologyIcon sx={{ color: '#6366f1' }} />
|
||||
<Typography fontWeight="600">Host Persona</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host Background"
|
||||
size="small"
|
||||
value={bible.host?.background || ''}
|
||||
onChange={(e) => handleUpdateHost('background', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Expertise Level"
|
||||
size="small"
|
||||
value={bible.host?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateHost('expertise_level', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vocal Style"
|
||||
size="small"
|
||||
value={bible.host?.vocal_style || ''}
|
||||
onChange={(e) => handleUpdateHost('vocal_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Audience DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<GroupsIcon sx={{ color: '#ec4899' }} />
|
||||
<Typography fontWeight="600">Audience DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Audience Expertise"
|
||||
size="small"
|
||||
value={bible.audience?.expertise_level || ''}
|
||||
onChange={(e) => handleUpdateAudience('expertise_level', e.target.value)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Interests
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.interests?.map((interest: string, idx: number) => (
|
||||
<Chip key={idx} label={interest} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{bible.audience?.pain_points?.map((point: string, idx: number) => (
|
||||
<Chip key={idx} label={point} size="small" color="error" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Brand DNA */}
|
||||
<Accordion sx={{ borderRadius: 2, '&:before': { display: 'none' }, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<BrandIcon sx={{ color: '#10b981' }} />
|
||||
<Typography fontWeight="600">Brand DNA</Typography>
|
||||
</Stack>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
size="small"
|
||||
value={bible.brand?.industry || ''}
|
||||
onChange={(e) => handleUpdateBrand('industry', e.target.value)}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Tone"
|
||||
size="small"
|
||||
value={bible.brand?.tone || ''}
|
||||
onChange={(e) => handleUpdateBrand('tone', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Style"
|
||||
size="small"
|
||||
value={bible.brand?.communication_style || ''}
|
||||
onChange={(e) => handleUpdateBrand('communication_style', e.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { Script } from "./types";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
import { ScriptEditor } from "./ScriptEditor";
|
||||
@@ -9,12 +8,14 @@ import { RenderQueue } from "./RenderQueue";
|
||||
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { PreflightBlockDialog } from "./PreflightBlockDialog";
|
||||
import { PodcastBiblePanel } from "./PodcastBiblePanel";
|
||||
import {
|
||||
Header,
|
||||
ProgressStepper,
|
||||
EstimateCard,
|
||||
QuerySelection,
|
||||
ResearchSummary,
|
||||
RegenerationFeedbackModal,
|
||||
usePodcastWorkflow,
|
||||
DEFAULT_KNOBS,
|
||||
getStepLabel,
|
||||
@@ -38,7 +39,9 @@ const PodcastDashboard: React.FC = () => {
|
||||
showScriptEditor,
|
||||
showRenderQueue,
|
||||
currentStep,
|
||||
bible,
|
||||
setScriptData,
|
||||
setBible,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
setResearchProvider,
|
||||
@@ -56,6 +59,8 @@ const PodcastDashboard: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const [showRegenModal, setShowRegenModal] = useState(false);
|
||||
|
||||
const handleSelectProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
await loadProjectFromDb(projectId);
|
||||
@@ -65,7 +70,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
// Use workflow's setAnnouncement - workflow is stable from hook
|
||||
workflow.setAnnouncement(errorMsg);
|
||||
}
|
||||
}, [loadProjectFromDb, workflow.setAnnouncement]);
|
||||
}, [loadProjectFromDb, workflow]);
|
||||
|
||||
const handleNewEpisode = useCallback(() => {
|
||||
resetState();
|
||||
@@ -184,6 +189,14 @@ const PodcastDashboard: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Podcast Bible */}
|
||||
{project && bible && (currentStep === 'analysis' || (currentStep === 'research' && !research)) && !showScriptEditor && !showRenderQueue && (
|
||||
<PodcastBiblePanel
|
||||
bible={bible}
|
||||
onUpdate={(updated) => setBible(updated)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(workflow.isAnalyzing || workflow.isResearching) && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
@@ -214,23 +227,22 @@ const PodcastDashboard: React.FC = () => {
|
||||
|
||||
{/* Main Content */}
|
||||
<Stack spacing={3}>
|
||||
{analysis && !showScriptEditor && !showRenderQueue && (
|
||||
{analysis && (currentStep === 'analysis' || (currentStep === 'research' && !research)) && !showScriptEditor && !showRenderQueue && (
|
||||
<AnalysisPanel
|
||||
analysis={analysis}
|
||||
estimate={estimate}
|
||||
idea={project?.idea}
|
||||
duration={project?.duration}
|
||||
speakers={project?.speakers}
|
||||
avatarUrl={project?.avatarUrl}
|
||||
avatarPrompt={project?.avatarPrompt}
|
||||
onRegenerate={() => {}}
|
||||
onRegenerate={() => setShowRegenModal(true)}
|
||||
onUpdateAnalysis={(updated) => projectState.setAnalysis(updated)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{estimate && !showScriptEditor && !showRenderQueue && (
|
||||
<EstimateCard estimate={estimate} />
|
||||
)}
|
||||
|
||||
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
|
||||
{/* Main content area */}
|
||||
{queries.length > 0 && currentStep === 'research' && !research && !showScriptEditor && !showRenderQueue && (
|
||||
<QuerySelection
|
||||
queries={queries}
|
||||
selectedQueries={selectedQueries}
|
||||
@@ -242,7 +254,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{research && !showScriptEditor && !showRenderQueue && (
|
||||
{research && (currentStep === 'research' || currentStep === 'script') && !showScriptEditor && !showRenderQueue && (
|
||||
<ResearchSummary
|
||||
research={research}
|
||||
canGenerateScript={workflow.canGenerateScript}
|
||||
@@ -260,6 +272,8 @@ const PodcastDashboard: React.FC = () => {
|
||||
speakers={project.speakers}
|
||||
durationMinutes={project.duration}
|
||||
script={scriptData}
|
||||
analysis={analysis}
|
||||
outline={analysis?.suggestedOutlines?.[0]}
|
||||
onScriptChange={(s) => setScriptData(s)}
|
||||
onBackToResearch={() => setShowScriptEditor(false)}
|
||||
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
|
||||
@@ -280,8 +294,10 @@ const PodcastDashboard: React.FC = () => {
|
||||
script={scriptData}
|
||||
knobs={knobsState}
|
||||
jobs={renderJobs}
|
||||
bible={bible}
|
||||
budgetCap={projectState.budgetCap}
|
||||
avatarImageUrl={null}
|
||||
analysis={analysis} // Pass analysis context
|
||||
onUpdateJob={updateRenderJob}
|
||||
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
|
||||
onBack={() => {
|
||||
@@ -305,6 +321,17 @@ const PodcastDashboard: React.FC = () => {
|
||||
response={workflow.preflightResponse}
|
||||
operationName={workflow.preflightOperationName}
|
||||
/>
|
||||
|
||||
{/* Regeneration Feedback Modal */}
|
||||
<RegenerationFeedbackModal
|
||||
open={showRegenModal}
|
||||
onClose={() => setShowRegenModal(false)}
|
||||
onConfirm={async (feedback) => {
|
||||
setShowRegenModal(false);
|
||||
await workflow.handleRegenerate(feedback);
|
||||
}}
|
||||
isSubmitting={workflow.isAnalyzing}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
Info as InfoIcon,
|
||||
@@ -19,21 +19,19 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
sx={{ width: "100%", minWidth: 0 }} // Ensure full width and allow wrapping
|
||||
>
|
||||
<Box sx={{ minWidth: 0, flex: { xs: "1 1 100%", md: "0 1 auto" } }}>
|
||||
<Stack sx={{ width: "100%", minWidth: 0 }} spacing={1.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
@@ -43,89 +41,86 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
|
||||
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
|
||||
AI Podcast Maker
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: "none", sm: "block" } }}>
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{
|
||||
justifyContent: { xs: "flex-start", md: "flex-end" },
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" }, // Full width on mobile to allow wrapping
|
||||
flex: { xs: "1 1 100%", md: "0 1 auto" }, // Take full width on mobile
|
||||
}}
|
||||
>
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton
|
||||
onClick={() => window.open("/docs", "_blank")}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
display: { xs: "none", lg: "flex" },
|
||||
// Override for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{
|
||||
justifyContent: { xs: "flex-start", md: "flex-end" },
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
sx={{
|
||||
display: { xs: "none", xl: "flex" },
|
||||
// Override for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={onShowProjects}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex !important", // Always show "My Projects" - force display
|
||||
order: { xs: 1, md: 0 }, // Show first on mobile
|
||||
// Override button colors for light theme
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={onNewEpisode}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex", // Always show "New Episode"
|
||||
order: { xs: 0, md: 1 }, // Show first on mobile
|
||||
}}
|
||||
>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton
|
||||
onClick={() => window.open("/docs", "_blank")}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
display: { xs: "none", lg: "flex" },
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
sx={{
|
||||
display: { xs: "none", xl: "flex" },
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={onShowProjects}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex !important",
|
||||
borderColor: "rgba(102, 126, 234, 0.3) !important",
|
||||
color: "#667eea !important",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102, 126, 234, 0.5) !important",
|
||||
background: "rgba(102, 126, 234, 0.1) !important",
|
||||
},
|
||||
}}
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={onNewEpisode}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ display: { xs: "none", sm: "block" } }}
|
||||
>
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
TextField,
|
||||
Box,
|
||||
Stack,
|
||||
Chip,
|
||||
alpha,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
Close as CloseIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
RecordVoiceOver as VoiceIcon,
|
||||
Groups as AudienceIcon,
|
||||
FormatListBulleted as OutlineIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface RegenerationFeedbackModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (feedback: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const feedbackOptions = [
|
||||
{ label: 'Audience is wrong', icon: <AudienceIcon fontSize="small" />, text: 'The target audience is not quite right. It should be more focused on...' },
|
||||
{ label: 'Too generic', icon: <AutoAwesomeIcon fontSize="small" />, text: 'The analysis feels a bit generic. Can we make it more specific to...' },
|
||||
{ label: 'Outline needs work', icon: <OutlineIcon fontSize="small" />, text: 'The suggested episode outlines don\'t capture the depth I want. Let\'s try...' },
|
||||
{ label: 'Wrong tone', icon: <VoiceIcon fontSize="small" />, text: 'The content type and tone don\'t match my brand. I want it to be more...' },
|
||||
];
|
||||
|
||||
export const RegenerationFeedbackModal: React.FC<RegenerationFeedbackModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isSubmitting = false
|
||||
}) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
const handleOptionClick = (text: string) => {
|
||||
setFeedback(prev => prev ? `${prev}\n${text}` : text);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onConfirm(feedback.trim());
|
||||
setFeedback('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
bgcolor: '#ffffff',
|
||||
backgroundImage: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<PsychologyIcon sx={{ color: '#4f46e5' }} />
|
||||
<Typography variant="h6" fontWeight={800} sx={{ color: '#1e293b' }}>
|
||||
Improve AI Analysis
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: '#64748b' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 3, pt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: '#475569', mb: 3 }}>
|
||||
Tell us what you'd like to change or improve about the previous analysis. Your feedback will help the AI generate a more accurate plan for your podcast.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, display: 'block', mb: 1.5 }}>
|
||||
QUICK SUGGESTIONS
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{feedbackOptions.map((opt) => (
|
||||
<Chip
|
||||
key={opt.label}
|
||||
label={opt.label}
|
||||
icon={opt.icon}
|
||||
onClick={() => handleOptionClick(opt.text)}
|
||||
sx={{
|
||||
bgcolor: alpha('#4f46e5', 0.05),
|
||||
color: '#4f46e5',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#4f46e5', 0.2),
|
||||
fontWeight: 600,
|
||||
'&:hover': {
|
||||
bgcolor: alpha('#4f46e5', 0.1),
|
||||
borderColor: '#4f46e5',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="e.g. Focus more on technical details for developers, or make the tone more humorous and conversational..."
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
variant="outlined"
|
||||
autoFocus
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: '#f8fafc',
|
||||
borderRadius: 2,
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
'&:hover fieldset': { borderColor: '#cbd5e1' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#4f46e5' },
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1e293b',
|
||||
fontSize: '0.95rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, pt: 0 }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
sx={{ color: '#64748b', textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!feedback.trim() || isSubmitting}
|
||||
sx={{
|
||||
bgcolor: '#4f46e5',
|
||||
color: 'white',
|
||||
px: 4,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
'&:hover': { bgcolor: '#4338ca' },
|
||||
'&.Mui-disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' }
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? 'Regenerating...' : 'Regenerate Analysis'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
Article as ArticleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research } from "../types";
|
||||
import { Research, ResearchInsight } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
|
||||
@@ -22,75 +23,46 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
}) => {
|
||||
// Extract key insights from summary if it's long
|
||||
const summaryParts = useMemo(() => {
|
||||
const fullSummary = research.summary || "";
|
||||
if (fullSummary.length > 500) {
|
||||
// Try to split into paragraphs or sentences
|
||||
const sentences = fullSummary.split(/[.!?]\s+/).filter(s => s.trim().length > 20);
|
||||
const keyPoints = sentences.slice(0, 3);
|
||||
const remainingText = sentences.slice(3).join(". ") + (sentences.length > 3 ? "." : "");
|
||||
return { keyPoints, remainingText };
|
||||
}
|
||||
return { keyPoints: [], remainingText: fullSummary };
|
||||
}, [research.summary]);
|
||||
// Simple markdown-to-HTML converter
|
||||
const renderMarkdown = useCallback((text: string) => {
|
||||
if (!text) return null;
|
||||
return text
|
||||
.split('\n')
|
||||
.filter(line => line.trim() !== '') // Remove empty lines
|
||||
.map((line, i) => {
|
||||
// Handle bold
|
||||
let processedLine = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
// Handle lists
|
||||
if (processedLine.trim().startsWith('- ') || processedLine.trim().startsWith('* ')) {
|
||||
return <li key={i} dangerouslySetInnerHTML={{ __html: processedLine.trim().substring(2) }} style={{ marginBottom: '4px', fontSize: '0.9rem' }} />;
|
||||
}
|
||||
// Handle headers - make them smaller
|
||||
if (processedLine.startsWith('### ')) {
|
||||
return <Typography key={i} variant="subtitle2" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#1e293b' }}>{processedLine.substring(4)}</Typography>;
|
||||
}
|
||||
if (processedLine.startsWith('## ')) {
|
||||
return <Typography key={i} variant="subtitle1" fontWeight={700} sx={{ mt: 1.5, mb: 0.5, color: '#0f172a' }}>{processedLine.substring(3)}</Typography>;
|
||||
}
|
||||
// Paragraphs - compact spacing
|
||||
return processedLine.trim() ? <p key={i} dangerouslySetInnerHTML={{ __html: processedLine }} style={{ margin: '4px 0', fontSize: '0.9rem' }} /> : null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<InsightsIcon />
|
||||
Research Summary
|
||||
</Typography>
|
||||
|
||||
{/* Key Insights */}
|
||||
{summaryParts.keyPoints.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<ArticleIcon fontSize="small" />
|
||||
Key Insights
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{summaryParts.keyPoints.map((point, idx) => (
|
||||
<Paper
|
||||
key={idx}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
background: alpha("#667eea", 0.05),
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.6, fontSize: "0.875rem" }}>
|
||||
{point}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Full Summary Text */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
lineHeight: 1.7,
|
||||
fontSize: "0.875rem",
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
{summaryParts.remainingText || research.summary}
|
||||
</Typography>
|
||||
|
||||
{/* Research Metadata */}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
|
||||
{/* Research Metadata - Moved alongside title */}
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap">
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Chip
|
||||
icon={<SearchIcon />}
|
||||
icon={<SearchIcon sx={{ fontSize: "1rem !important" }} />}
|
||||
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
@@ -139,32 +111,8 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Search Queries Used */}
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
|
||||
Search Queries Used
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{research.searchQueries.map((query, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: "rgba(102, 126, 234, 0.3)",
|
||||
color: "#475569",
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
fontSize: "0.8125rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
@@ -175,6 +123,153 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ width: "100%" }}>
|
||||
{/* Main Summary */}
|
||||
{research.summary && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 3,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em", display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
|
||||
Executive Summary
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
lineHeight: 1.6,
|
||||
fontSize: "0.9rem",
|
||||
color: "#334155",
|
||||
"& p": { m: 0, mb: 1 },
|
||||
"& ul": { m: 0, mb: 1, pl: 2.5 },
|
||||
"& li": { mb: 0.5 },
|
||||
"& strong": { color: "#0f172a", fontWeight: 600 }
|
||||
}}>
|
||||
{renderMarkdown(research.summary)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Deep Insights */}
|
||||
{(research.keyInsights && research.keyInsights.length > 0) ? (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<ArticleIcon sx={{ color: "#667eea" }} />
|
||||
Deep Insights
|
||||
</Typography>
|
||||
<Stack spacing={2.5}>
|
||||
{research.keyInsights.map((insight: ResearchInsight, idx: number) => (
|
||||
<Paper
|
||||
key={idx}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||
{insight.title}
|
||||
</Typography>
|
||||
{insight.source_indices && insight.source_indices.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
{insight.source_indices.map(sIdx => (
|
||||
<Chip
|
||||
key={sIdx}
|
||||
label={`S${sIdx}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
borderColor: alpha("#667eea", 0.3),
|
||||
color: "#667eea",
|
||||
bgcolor: alpha("#667eea", 0.05)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Box sx={{
|
||||
color: "#475569",
|
||||
lineHeight: 1.7,
|
||||
fontSize: "0.9rem",
|
||||
"& p": { m: 0, mb: 1.5 },
|
||||
"& ul": { m: 0, mb: 1.5, pl: 2 }
|
||||
}}>
|
||||
{renderMarkdown(insight.content)}
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
/* Fallback if keyInsights is missing but we have summary paragraphs */
|
||||
research.summary && research.summary.length > 500 && !research.keyInsights && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<ArticleIcon sx={{ color: "#667eea" }} />
|
||||
Additional Insights
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 2px 12px rgba(0,0,0,0.03)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
color: "#475569",
|
||||
lineHeight: 1.7,
|
||||
fontSize: "0.9rem",
|
||||
}}>
|
||||
{/* Render parts of summary that might contain insights if structured data is missing */}
|
||||
{renderMarkdown(research.summary.split('\n\n').slice(1).join('\n\n'))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Search Queries Used */}
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: "1px solid rgba(0,0,0,0.04)" }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: "#64748b", fontWeight: 700, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Search Queries Used
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{research.searchQueries.map((query, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: "rgba(102, 126, 234, 0.15)",
|
||||
color: "#94a3b8",
|
||||
background: alpha("#f8fafc", 0.3),
|
||||
fontSize: "0.7rem",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{research.factCards.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
@@ -3,6 +3,6 @@ export { ProgressStepper } from "./ProgressStepper";
|
||||
export { EstimateCard } from "./EstimateCard";
|
||||
export { QuerySelection } from "./QuerySelection";
|
||||
export { ResearchSummary } from "./ResearchSummary";
|
||||
export { RegenerationFeedbackModal } from "./RegenerationFeedbackModal";
|
||||
export { usePodcastWorkflow } from "./usePodcastWorkflow";
|
||||
export * from "./utils";
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
||||
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
|
||||
import { CreateProjectPayload, Script } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
|
||||
@@ -43,6 +42,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
initializeProject,
|
||||
setBible,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -75,7 +75,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setShowResumeAlert(true);
|
||||
setTimeout(() => setShowResumeAlert(false), 5000);
|
||||
}
|
||||
}, []); // Only on mount
|
||||
}, [project, currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
@@ -85,7 +85,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
return undefined;
|
||||
}, [announcement]);
|
||||
|
||||
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
|
||||
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
|
||||
if (isAnalyzing) return;
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
@@ -95,8 +95,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// Upload avatar if provided, or generate presenters
|
||||
let avatarUrl: string | null = null;
|
||||
// Use existing avatar URL if provided (e.g. brand avatar), or upload new file
|
||||
let avatarUrl: string | null = payload.avatarUrl || null;
|
||||
if (payload.files.avatarFile) {
|
||||
try {
|
||||
setAnnouncement("Uploading presenter avatar...");
|
||||
@@ -108,10 +108,46 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject({ ...payload, avatarUrl });
|
||||
await initializeProject(payload, result.projectId);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers, avatarUrl });
|
||||
// NEW FLOW: Create project first to generate/get the Podcast Bible
|
||||
// This allows the analysis to be personalized using the Bible context
|
||||
const projectId = project?.id || `podcast_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
setAnnouncement("Initializing project and brand context...");
|
||||
const dbProject = project ? null : await initializeProject(payload, projectId, avatarUrl);
|
||||
const bible = dbProject?.bible || projectState.bible;
|
||||
|
||||
setAnnouncement(feedback ? "Regenerating analysis using your feedback..." : "Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload, bible, feedback);
|
||||
|
||||
if (result.bible) {
|
||||
setBible(result.bible);
|
||||
} else if (dbProject?.bible) {
|
||||
setBible(dbProject.bible);
|
||||
}
|
||||
|
||||
// Update the project in database with the analysis results
|
||||
try {
|
||||
await podcastApi.updateProject(projectId, {
|
||||
analysis: result.analysis,
|
||||
estimate: result.estimate,
|
||||
queries: result.queries,
|
||||
selected_queries: result.queries.map(q => q.id),
|
||||
avatar_url: result.avatar_url,
|
||||
avatar_prompt: result.avatar_prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update project with analysis results:', error);
|
||||
}
|
||||
|
||||
setProject({
|
||||
id: projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
avatarUrl: result.avatar_url || avatarUrl,
|
||||
avatarPrompt: result.avatar_prompt || null,
|
||||
avatarPersonaId: null,
|
||||
});
|
||||
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
@@ -188,7 +224,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
|
||||
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap, setBible]);
|
||||
|
||||
const handleRunResearch = useCallback(async () => {
|
||||
if (isResearching) return;
|
||||
@@ -230,6 +266,8 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
approvedQueries,
|
||||
provider: researchProvider,
|
||||
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
|
||||
bible: projectState.bible,
|
||||
analysis: analysis,
|
||||
onProgress: (message) => {
|
||||
setAnnouncement(message);
|
||||
},
|
||||
@@ -258,7 +296,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
|
||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
|
||||
|
||||
const handleGenerateScript = useCallback(async () => {
|
||||
if (showScriptEditor) return;
|
||||
@@ -282,7 +320,25 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
try {
|
||||
const result = await podcastApi.generateScript({
|
||||
projectId: project.id,
|
||||
idea: project.idea,
|
||||
research: rawResearch,
|
||||
knobs: projectState.knobs,
|
||||
speakers: project.speakers,
|
||||
durationMinutes: project.duration,
|
||||
bible: projectState.bible,
|
||||
outline: analysis?.suggestedOutlines?.[0], // Pass the first (possibly refined) outline
|
||||
analysis: analysis, // Pass full analysis context
|
||||
});
|
||||
|
||||
setScriptData(result);
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
}
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible])
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
setScriptData(script);
|
||||
@@ -316,13 +372,30 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
if (research) return 1;
|
||||
if (analysis) return 0;
|
||||
if (currentStep === 'research' || research) return 1;
|
||||
if (currentStep === 'analysis' || analysis) return 0;
|
||||
return -1;
|
||||
}, [showRenderQueue, showScriptEditor, research, analysis]);
|
||||
}, [showRenderQueue, showScriptEditor, currentStep, research, analysis]);
|
||||
|
||||
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||
|
||||
const handleRegenerate = useCallback(async (feedback?: string) => {
|
||||
if (!project) return;
|
||||
|
||||
// Prepare the payload from existing project state
|
||||
const payload: CreateProjectPayload = {
|
||||
ideaOrUrl: project.idea,
|
||||
duration: project.duration,
|
||||
speakers: project.speakers,
|
||||
knobs: projectState.knobs,
|
||||
budgetCap: projectState.budgetCap,
|
||||
avatarUrl: project.avatarUrl,
|
||||
files: {} // No new files for regeneration
|
||||
};
|
||||
|
||||
await handleCreate(payload, feedback);
|
||||
}, [project, projectState.knobs, projectState.budgetCap, handleCreate]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAnalyzing,
|
||||
@@ -336,6 +409,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
handleCreate,
|
||||
handleRegenerate,
|
||||
handleRunResearch,
|
||||
handleGenerateScript,
|
||||
handleProceedToRendering,
|
||||
|
||||
@@ -20,8 +20,10 @@ interface RenderQueueProps {
|
||||
script: Script;
|
||||
knobs: Knobs;
|
||||
jobs: Job[];
|
||||
bible?: any | null;
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
analysis?: any | null; // Add analysis prop
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
onUpdateScript?: (script: Script) => void;
|
||||
onBack: () => void;
|
||||
@@ -33,8 +35,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
script,
|
||||
knobs,
|
||||
jobs,
|
||||
bible,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
analysis,
|
||||
onUpdateJob,
|
||||
onUpdateScript,
|
||||
onBack,
|
||||
@@ -57,6 +61,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
jobs,
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
@@ -167,41 +172,124 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Info Alert */}
|
||||
<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>
|
||||
{/* Compact Status Dashboard */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 2,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
{/* Status Chips */}
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
background: alpha("#6366f1", 0.08),
|
||||
color: "#4f46e5",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
border: "1px solid",
|
||||
borderColor: alpha("#6366f1", 0.2),
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={700} sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Scenes
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" fontWeight={800}>
|
||||
{script.scenes.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<SummaryStats jobs={jobs} scenes={script.scenes} />
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
background: script.scenes.every(s => s.audioUrl)
|
||||
? alpha("#10b981", 0.1)
|
||||
: alpha("#f59e0b", 0.1),
|
||||
color: script.scenes.every(s => s.audioUrl) ? "#059669" : "#d97706",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
border: "1px solid",
|
||||
borderColor: script.scenes.every(s => s.audioUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={700}>
|
||||
Audio
|
||||
</Typography>
|
||||
{script.scenes.every(s => s.audioUrl) ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<Typography variant="subtitle2" fontWeight={800}>
|
||||
{script.scenes.filter(s => s.audioUrl).length}/{script.scenes.length}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{jobs.length === 0 && script.scenes.length === 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
|
||||
border: "2px dashed rgba(102, 126, 234, 0.3)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
|
||||
No scenes to render
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
|
||||
Go back to the script editor to generate and approve scenes first.
|
||||
</Typography>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script Editor
|
||||
</SecondaryButton>
|
||||
</Paper>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: 2,
|
||||
background: script.scenes.every(s => s.imageUrl)
|
||||
? alpha("#10b981", 0.1)
|
||||
: alpha("#f59e0b", 0.1),
|
||||
color: script.scenes.every(s => s.imageUrl) ? "#059669" : "#d97706",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
border: "1px solid",
|
||||
borderColor: script.scenes.every(s => s.imageUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" fontWeight={700}>
|
||||
Images
|
||||
</Typography>
|
||||
{script.scenes.every(s => s.imageUrl) ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<Typography variant="subtitle2" fontWeight={800}>
|
||||
{script.scenes.filter(s => s.imageUrl).length}/{script.scenes.length}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Guidance Panel */}
|
||||
{script.scenes.length > 0 && <GuidancePanel scenes={script.scenes} />}
|
||||
{/* Dynamic Guidance Message */}
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 500, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box component="span" sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: allVideosReady ? "#10b981" : "#3b82f6",
|
||||
display: "inline-block"
|
||||
}} />
|
||||
{allVideosReady
|
||||
? "All assets ready. You can combine videos below."
|
||||
: !script.scenes.every(s => s.audioUrl)
|
||||
? "Generate audio for all scenes to proceed."
|
||||
: !script.scenes.every(s => s.imageUrl)
|
||||
? "Generate images for video backgrounds."
|
||||
: "Ready to generate scene videos."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Scene Cards */}
|
||||
<Stack spacing={2}>
|
||||
@@ -216,6 +304,8 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
generatingImage={generatingImage}
|
||||
isBusy={isBusy}
|
||||
avatarImageUrl={avatarImageUrl}
|
||||
bible={bible}
|
||||
analysis={analysis}
|
||||
onRender={runRender}
|
||||
onImageGenerate={runImageGeneration}
|
||||
onVideoGenerate={(sceneId, settings) => runVideoRender(sceneId, settings)}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React from "react";
|
||||
import { Stack } from "@mui/material";
|
||||
import { Stack, alpha } from "@mui/material";
|
||||
import {
|
||||
VolumeUp as VolumeUpIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Image as ImageIcon,
|
||||
Videocam as VideocamIcon,
|
||||
Download as DownloadIcon,
|
||||
Share as ShareIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job } from "../types";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { Typography } from "@mui/material"; // Import Typography
|
||||
|
||||
interface SceneActionButtonsProps {
|
||||
scene: Scene;
|
||||
@@ -76,7 +77,26 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Failed - show retry
|
||||
// Video generation failed - show specific retry for video
|
||||
if (job?.status === "failed" && !needsAudio && hasAudio) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<Typography variant="caption" color="error" sx={{ alignSelf: "center", mr: 1 }}>
|
||||
Video Generation Failed
|
||||
</Typography>
|
||||
<SecondaryButton
|
||||
onClick={() => onVideoRender(scene.id)}
|
||||
startIcon={<RefreshIcon />}
|
||||
tooltip="Retry video generation"
|
||||
sx={{ borderColor: "error.main", color: "error.main" }}
|
||||
>
|
||||
Retry Video
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Failed (Audio) - show retry
|
||||
if (job?.status === "failed") {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
@@ -85,7 +105,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
startIcon={<RefreshIcon />}
|
||||
tooltip="Retry audio generation"
|
||||
>
|
||||
Retry
|
||||
Retry Audio
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
);
|
||||
@@ -97,40 +117,49 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
|
||||
{/* Generate Image */}
|
||||
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
|
||||
<PrimaryButton
|
||||
onClick={() => onImageGenerate(scene.id)}
|
||||
disabled={isGeneratingImage || hasImage}
|
||||
disabled={isGeneratingImage}
|
||||
loading={isGeneratingImage}
|
||||
startIcon={<ImageIcon />}
|
||||
tooltip={
|
||||
hasImage
|
||||
? "Image already generated for this scene"
|
||||
: isGeneratingImage
|
||||
isGeneratingImage
|
||||
? "Generating image..."
|
||||
: hasImage
|
||||
? "Regenerate image for this scene"
|
||||
: "Generate image for video (optional)"
|
||||
}
|
||||
sx={{ minWidth: 160 }}
|
||||
sx={{
|
||||
minWidth: 160,
|
||||
// Use secondary style if image exists (to de-emphasize), primary if needed
|
||||
background: hasImage ? alpha("#667eea", 0.1) : undefined,
|
||||
color: hasImage ? "#667eea" : undefined,
|
||||
border: hasImage ? "1px solid rgba(102,126,234,0.3)" : undefined,
|
||||
"&:hover": {
|
||||
background: hasImage ? alpha("#667eea", 0.2) : undefined,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
|
||||
{isGeneratingImage ? "Generating..." : hasImage ? "Regenerate Image" : "Generate Image"}
|
||||
</PrimaryButton>
|
||||
|
||||
{/* Generate Video */}
|
||||
{/* Generate Video - ALWAYS visible if we have audio */}
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
onVideoRender(scene.id);
|
||||
}}
|
||||
disabled={isBusy || videoInProgress || !hasImage || hasVideo}
|
||||
disabled={isBusy || videoInProgress || !hasImage}
|
||||
startIcon={<VideocamIcon />}
|
||||
tooltip={
|
||||
hasVideo
|
||||
? "Video already generated"
|
||||
: !hasImage
|
||||
!hasImage
|
||||
? "Generate an image first to create video"
|
||||
: videoInProgress
|
||||
? "A video generation is already running. Please wait..."
|
||||
: isBusy
|
||||
? "Another operation in progress"
|
||||
: hasVideo
|
||||
? "Regenerate video"
|
||||
: "Generate video for this scene"
|
||||
}
|
||||
sx={{ minWidth: 180 }}
|
||||
@@ -138,7 +167,7 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
{videoInProgress && isCurrentVideo
|
||||
? "Generating Video..."
|
||||
: hasVideo
|
||||
? "Video Ready"
|
||||
? "Regenerate Video"
|
||||
: "Generate Video"}
|
||||
</PrimaryButton>
|
||||
|
||||
@@ -154,36 +183,48 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
)}
|
||||
|
||||
{/* Download Audio */}
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!audioUrl) {
|
||||
onError("Audio URL not found. Please regenerate audio.");
|
||||
return;
|
||||
}
|
||||
onDownloadAudio(audioUrl, scene.title);
|
||||
}}
|
||||
startIcon={<DownloadIcon />}
|
||||
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
|
||||
disabled={!hasAudio}
|
||||
>
|
||||
Download Audio
|
||||
</SecondaryButton>
|
||||
{hasAudio && audioUrl && (
|
||||
<PrimaryButton
|
||||
onClick={() => onDownloadAudio(audioUrl, scene.title)}
|
||||
startIcon={<DownloadIcon />}
|
||||
tooltip="Download audio file"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
width: 40,
|
||||
padding: 0,
|
||||
background: alpha("#64748b", 0.1),
|
||||
color: "#64748b",
|
||||
border: "1px solid rgba(100, 116, 139, 0.2)",
|
||||
"&:hover": {
|
||||
background: alpha("#64748b", 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Icon only */}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!audioUrl) {
|
||||
onError("Audio URL not found. Please regenerate audio.");
|
||||
return;
|
||||
}
|
||||
onShare(audioUrl, scene.title);
|
||||
}}
|
||||
startIcon={<ShareIcon />}
|
||||
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
|
||||
disabled={!hasAudio}
|
||||
>
|
||||
Share
|
||||
</SecondaryButton>
|
||||
{hasAudio && audioUrl && (
|
||||
<PrimaryButton
|
||||
onClick={() => onShare(audioUrl, scene.title)}
|
||||
startIcon={<ShareIcon />}
|
||||
tooltip="Share audio link"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
width: 40,
|
||||
padding: 0,
|
||||
background: alpha("#64748b", 0.1),
|
||||
color: "#64748b",
|
||||
border: "1px solid rgba(100, 116, 139, 0.2)",
|
||||
"&:hover": {
|
||||
background: alpha("#64748b", 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Icon only */}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha, Modal, IconButton } from "@mui/material";
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
Info as InfoIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Videocam as VideocamIcon,
|
||||
Close as CloseIcon,
|
||||
ZoomIn as ZoomInIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job, VideoGenerationSettings } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
@@ -22,6 +24,8 @@ interface SceneCardProps {
|
||||
generatingImage: string | null;
|
||||
isBusy: boolean;
|
||||
avatarImageUrl?: string | null;
|
||||
bible?: any;
|
||||
analysis?: any;
|
||||
onRender: (sceneId: string, mode: "preview" | "full") => void;
|
||||
onImageGenerate: (sceneId: string) => void;
|
||||
onVideoGenerate: (sceneId: string, settings: VideoGenerationSettings) => void;
|
||||
@@ -75,6 +79,8 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
generatingImage,
|
||||
isBusy,
|
||||
avatarImageUrl,
|
||||
bible,
|
||||
analysis,
|
||||
onRender,
|
||||
onImageGenerate,
|
||||
onVideoGenerate,
|
||||
@@ -96,6 +102,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
|
||||
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
|
||||
const [showVideoModal, setShowVideoModal] = useState(false);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
|
||||
|
||||
// Prepare a simple default prompt based on the scene title/description
|
||||
@@ -261,96 +268,151 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
{/* Visual Avatar */}
|
||||
<Paper
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: 48,
|
||||
height: 48,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: alpha("#667eea", 0.2),
|
||||
border: "1px solid rgba(102,126,234,0.3)",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.2rem",
|
||||
fontSize: "1.1rem",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.25)",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Paper>
|
||||
|
||||
{/* Title and Metadata */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1.05rem" }}>
|
||||
{scene.title}
|
||||
</Typography>
|
||||
|
||||
{/* Quick Downloads */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
{job?.finalUrl && (
|
||||
<Box
|
||||
component="a"
|
||||
href={job.finalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
"&:hover": { color: "#6366f1" }
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 14 }} />
|
||||
Audio
|
||||
</Box>
|
||||
)}
|
||||
{hasVideo && videoBlobUrl && (
|
||||
<Box
|
||||
component="a"
|
||||
href={videoBlobUrl}
|
||||
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
"&:hover": { color: "#6366f1" }
|
||||
}}
|
||||
>
|
||||
<VideocamIcon sx={{ fontSize: 14 }} />
|
||||
Video
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Compact Metadata Row */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }} useFlexGap>
|
||||
{/* Scene ID */}
|
||||
<Chip
|
||||
label={`Scene ${scene.id.slice(-4)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
background: alpha("#64748b", 0.08),
|
||||
color: "#64748b",
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Audio Status */}
|
||||
<Chip
|
||||
label={hasAudio ? "Audio Ready" : "Needs Audio"}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
background: hasAudio ? alpha("#10b981", 0.1) : alpha("#f59e0b", 0.1),
|
||||
color: hasAudio ? "#059669" : "#d97706",
|
||||
fontWeight: 700,
|
||||
border: "1px solid",
|
||||
borderColor: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cost */}
|
||||
{job?.cost != null && (
|
||||
<Chip
|
||||
label={`$${job.cost.toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
|
||||
title="Generation cost"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
background: alpha("#6366f1", 0.08),
|
||||
color: "#6366f1",
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{job?.fileSize && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(job.fileSize / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{!job && (
|
||||
|
||||
{/* Job Status (if active/failed) */}
|
||||
{job && job.status !== "idle" && (
|
||||
<Chip
|
||||
label={hasAudio ? "Audio Ready" : "Needs Audio"}
|
||||
icon={getStatusIcon(status)}
|
||||
label={status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
size="small"
|
||||
color={hasAudio ? "success" : "warning"}
|
||||
sx={{
|
||||
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
|
||||
color: hasAudio ? "#059669" : "#d97706",
|
||||
fontWeight: 600,
|
||||
height: 20,
|
||||
fontSize: "0.7rem",
|
||||
background: status === "completed" ? alpha("#10b981", 0.1) : status === "failed" ? alpha("#ef4444", 0.1) : alpha("#3b82f6", 0.1),
|
||||
color: status === "completed" ? "#059669" : status === "failed" ? "#dc2626" : "#2563eb",
|
||||
fontWeight: 700,
|
||||
"& .MuiChip-icon": { fontSize: 14, color: "inherit" }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{job?.finalUrl && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
component="a"
|
||||
href={job.finalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption">Download Final Audio</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{hasVideo && videoBlobUrl && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
component="a"
|
||||
href={videoBlobUrl}
|
||||
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
|
||||
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<VideocamIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption">Download Video</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{job && (
|
||||
<Chip
|
||||
icon={getStatusIcon(status)}
|
||||
label={status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
color={getStatusColor(status)}
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
minWidth: 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Audio Player - Now directly in header section (visual integration) */}
|
||||
{hasAudio && audioUrl && (
|
||||
<Box sx={{ width: "100%", mt: 1 }}>
|
||||
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{job && job.status !== "idle" && job.status !== "completed" && (
|
||||
<Box>
|
||||
@@ -379,20 +441,6 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
|
||||
{/* Success Alert for Pre-generated Audio */}
|
||||
{hasAudio && !job && (
|
||||
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
|
||||
✅ Audio already generated in Script Editor. Ready to use!
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{hasAudio && audioUrl && (
|
||||
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
|
||||
)}
|
||||
|
||||
{/* Video Preview - Show video if available, otherwise show image */}
|
||||
{hasVideo && videoBlobUrl ? (
|
||||
<Box
|
||||
@@ -415,7 +463,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 420,
|
||||
objectFit: "cover",
|
||||
objectFit: "contain",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
onError={(e) => {
|
||||
@@ -443,34 +491,62 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
VIDEO
|
||||
</Box>
|
||||
</Box>
|
||||
) : hasImage && (imageBlobUrl || imageUrl) ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
}}
|
||||
>
|
||||
) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? (
|
||||
<Box sx={{ position: "relative", width: "100%" }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || imageUrl}
|
||||
alt={scene.title}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 400,
|
||||
objectFit: "cover",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
cursor: "pointer",
|
||||
"&:hover .zoom-icon": {
|
||||
opacity: 1,
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("[SceneCard] Image failed to load:", {
|
||||
src: e.currentTarget.src,
|
||||
imageUrl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
onClick={() => setShowImageModal(true)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || imageUrl}
|
||||
alt={scene.title}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 400,
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("[SceneCard] Image failed to load:", {
|
||||
src: e.currentTarget.src,
|
||||
imageUrl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
className="zoom-icon"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
bgcolor: "rgba(0,0,0,0.6)",
|
||||
color: "white",
|
||||
borderRadius: "50%",
|
||||
p: 1.5,
|
||||
opacity: 0,
|
||||
transition: "opacity 0.2s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ZoomInIcon sx={{ fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -505,6 +581,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
initialPrompt={initialVideoPrompt}
|
||||
initialResolution="480p"
|
||||
initialSeed={-1}
|
||||
sceneTitle={scene.title}
|
||||
bible={bible}
|
||||
analysis={analysis}
|
||||
/>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -26,6 +26,10 @@ interface VideoRegenerateModalProps {
|
||||
initialPrompt: string;
|
||||
initialResolution?: "480p" | "720p";
|
||||
initialSeed?: number | null;
|
||||
// Add context props
|
||||
sceneTitle?: string;
|
||||
bible?: any;
|
||||
analysis?: any;
|
||||
}
|
||||
|
||||
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
||||
@@ -35,17 +39,45 @@ export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
|
||||
initialPrompt,
|
||||
initialResolution = "480p",
|
||||
initialSeed = -1,
|
||||
sceneTitle,
|
||||
bible,
|
||||
analysis,
|
||||
}) => {
|
||||
// Use a more intelligent default prompt based on context if available
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
|
||||
// Update prompt when context changes or modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
let smartPrompt = initialPrompt;
|
||||
|
||||
// If the initial prompt is generic/empty, try to build a better one
|
||||
if (!smartPrompt || smartPrompt === "Professional podcast scene with subtle movement") {
|
||||
const parts = [];
|
||||
|
||||
// Add scene context
|
||||
if (sceneTitle) parts.push(`Scene: ${sceneTitle}`);
|
||||
|
||||
// Add bible/persona context
|
||||
if (bible?.host_persona) parts.push(`Host Persona: ${bible.host_persona}`);
|
||||
if (bible?.tone) parts.push(`Tone: ${bible.tone}`);
|
||||
|
||||
// Add analysis context
|
||||
if (analysis?.content_type) parts.push(`Style: ${analysis.content_type}`);
|
||||
|
||||
// Combine into a descriptive prompt
|
||||
if (parts.length > 0) {
|
||||
smartPrompt = `Professional talking head video for podcast. ${parts.join(". ")}. Cinematic lighting, 4k, high detail.`;
|
||||
}
|
||||
}
|
||||
setPrompt(smartPrompt);
|
||||
}
|
||||
}, [open, initialPrompt, sceneTitle, bible, analysis]);
|
||||
|
||||
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
|
||||
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
|
||||
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(initialPrompt);
|
||||
setResolution(initialResolution);
|
||||
}, [initialResolution, initialPrompt]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
|
||||
const settings: VideoGenerationSettings = {
|
||||
|
||||
@@ -7,6 +7,7 @@ interface UseRenderQueueProps {
|
||||
jobs: Job[];
|
||||
knobs: Knobs;
|
||||
projectId: string;
|
||||
bible?: any | null;
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
@@ -21,6 +22,7 @@ export const useRenderQueue = ({
|
||||
jobs,
|
||||
knobs,
|
||||
projectId,
|
||||
bible,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
@@ -441,6 +443,7 @@ export const useRenderQueue = ({
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
baseAvatarUrl: avatarImageUrl || undefined, // Use base avatar if available
|
||||
bible: bible,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
@@ -544,6 +547,7 @@ export const useRenderQueue = ({
|
||||
sceneTitle: scene.title,
|
||||
audioUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
bible: bible,
|
||||
resolution: targetResolution,
|
||||
prompt: settings?.prompt || undefined,
|
||||
seed: settings?.seed ?? -1,
|
||||
|
||||
@@ -23,6 +23,8 @@ interface ScriptEditorProps {
|
||||
onProceedToRendering: (script: Script) => void;
|
||||
onError: (message: string) => void;
|
||||
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
|
||||
analysis?: any;
|
||||
outline?: any;
|
||||
}
|
||||
|
||||
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
@@ -39,6 +41,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
onProceedToRendering,
|
||||
onError,
|
||||
avatarUrl,
|
||||
analysis,
|
||||
outline,
|
||||
}) => {
|
||||
const [script, setScript] = useState<Script | null>(initialScript);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -89,6 +93,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
analysis,
|
||||
outline,
|
||||
})
|
||||
.then((res) => {
|
||||
if (mounted) {
|
||||
@@ -106,7 +112,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
|
||||
|
||||
const updateScene = (updated: Scene) => {
|
||||
// Use functional update to ensure we're working with latest state
|
||||
|
||||
@@ -20,10 +20,20 @@ export type Fact = {
|
||||
url: string;
|
||||
date: string;
|
||||
confidence: number;
|
||||
image?: string;
|
||||
author?: string;
|
||||
highlights?: string[];
|
||||
};
|
||||
|
||||
export type ResearchInsight = {
|
||||
title: string;
|
||||
content: string;
|
||||
source_indices: number[];
|
||||
};
|
||||
|
||||
export type Research = {
|
||||
summary: string;
|
||||
keyInsights: ResearchInsight[];
|
||||
factCards: Fact[];
|
||||
mappedAngles: {
|
||||
title: string;
|
||||
@@ -94,6 +104,7 @@ export type PodcastAnalysis = {
|
||||
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||
suggestedKnobs: Knobs;
|
||||
titleSuggestions: string[];
|
||||
research_queries?: { query: string; rationale: string }[];
|
||||
exaSuggestedConfig?: {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
exa_category?: string;
|
||||
@@ -113,6 +124,37 @@ export type PodcastEstimate = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type HostPersona = {
|
||||
name: string;
|
||||
background: string;
|
||||
expertise_level: string;
|
||||
personality_traits: string[];
|
||||
vocal_style: string;
|
||||
catchphrases: string[];
|
||||
};
|
||||
|
||||
export type AudienceDNA = {
|
||||
expertise_level: string;
|
||||
interests: string[];
|
||||
pain_points: string[];
|
||||
demographics?: string;
|
||||
};
|
||||
|
||||
export type BrandDNA = {
|
||||
industry: string;
|
||||
tone: string;
|
||||
communication_style: string;
|
||||
key_messages: string[];
|
||||
competitor_context?: string;
|
||||
};
|
||||
|
||||
export type PodcastBible = {
|
||||
project_id?: string;
|
||||
host: HostPersona;
|
||||
audience: AudienceDNA;
|
||||
brand: BrandDNA;
|
||||
};
|
||||
|
||||
export type CreateProjectPayload = {
|
||||
ideaOrUrl: string;
|
||||
speakers: number;
|
||||
@@ -128,6 +170,9 @@ export type CreateProjectResult = {
|
||||
analysis: PodcastAnalysis;
|
||||
estimate: PodcastEstimate;
|
||||
queries: Query[];
|
||||
bible?: PodcastBible;
|
||||
avatar_url?: string | null;
|
||||
avatar_prompt?: string | null;
|
||||
};
|
||||
|
||||
export type RenderJobResult = {
|
||||
|
||||
Reference in New Issue
Block a user