Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -262,8 +262,36 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}
setMetadataResult(result);
setEditableMetadata(result);
const sanitizeMetadata = (data: any) => {
const safe = { ...data };
safe.seo_title = safe.seo_title ?? '';
safe.meta_description = safe.meta_description ?? '';
safe.url_slug = safe.url_slug ?? '';
safe.focus_keyword = safe.focus_keyword ?? '';
safe.reading_time = typeof safe.reading_time === 'number' ? safe.reading_time : 0;
safe.blog_tags = Array.isArray(safe.blog_tags) ? safe.blog_tags : [];
safe.blog_categories = Array.isArray(safe.blog_categories) ? safe.blog_categories : [];
safe.social_hashtags = Array.isArray(safe.social_hashtags) ? safe.social_hashtags : [];
safe.open_graph = {
...(safe.open_graph || {}),
title: safe.open_graph?.title ?? '',
description: safe.open_graph?.description ?? '',
image: safe.open_graph?.image ?? '',
url: safe.open_graph?.url ?? ''
};
safe.twitter_card = {
...(safe.twitter_card || {}),
title: safe.twitter_card?.title ?? '',
description: safe.twitter_card?.description ?? '',
image: safe.twitter_card?.image ?? '',
site: safe.twitter_card?.site ?? ''
};
safe.json_ld_schema = { ...(safe.json_ld_schema || {}) };
return safe;
};
const sanitized = sanitizeMetadata(result);
setMetadataResult(sanitized);
setEditableMetadata(sanitized);
console.log('📊 Metadata result set:', result);
} catch (err: any) {

View File

@@ -652,7 +652,7 @@ export const AssetLibrary: React.FC = () => {
Asset Library
</Typography>
<Typography variant="body1" color="text.secondary">
Unified content archive for all ALwrity tools: Story Writer, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more.
Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more.
</Typography>
</Box>

View File

@@ -17,12 +17,14 @@ import {
Warning
} from '@mui/icons-material';
import OnboardingButton from '../common/OnboardingButton';
import { useNavigate } from 'react-router-dom';
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components';
import { FinalStepProps, OnboardingData, Capability } from './types';
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam';
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -297,22 +299,9 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
localStorage.setItem('onboarding_active_step', String(stepsLengthFallback()));
} catch {}
// Navigate directly to dashboard without calling onContinue
// This bypasses the wizard flow and goes straight to the dashboard
console.log('FinalStep: Navigating to dashboard...');
console.log('FinalStep: Setting window.location.href to /dashboard');
// Try multiple navigation methods to ensure redirect works
try {
window.location.href = '/dashboard';
console.log('FinalStep: window.location.href set successfully');
} catch (navError) {
console.error('FinalStep: window.location.href failed:', navError);
console.log('FinalStep: Trying alternative navigation method...');
window.location.assign('/dashboard');
}
console.log('FinalStep: Navigation initiated');
// Navigate directly to dashboard using React Router
console.log('FinalStep: Navigating to dashboard with react-router navigate("/dashboard")');
navigate('/dashboard', { replace: true });
} catch (e: any) {
console.error('FinalStep: Error completing onboarding:', e);
console.error('FinalStep: Error details:', {
@@ -528,26 +517,27 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
onClick={handleLaunch}
startIcon={<Rocket />}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
fontSize: '1.125rem',
fontWeight: 600,
px: 4,
py: 2,
borderRadius: 2,
textTransform: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
'&:hover': {
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
transform: 'translateY(-1px)',
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
},
'&:disabled': {
background: 'rgba(0,0,0,0.1)',
color: 'rgba(0,0,0,0.4)',
boxShadow: 'none',
transform: 'none',
}
}}
background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
fontSize: '1.125rem',
fontWeight: 600,
px: 4,
py: 2,
borderRadius: 999,
textTransform: 'none',
boxShadow: '0 10px 28px rgba(15,23,42,0.45)',
letterSpacing: 0.2,
'&:hover': {
background: 'linear-gradient(135deg, #020617 0%, #1e1b4b 40%, #4338ca 100%)',
transform: 'translateY(-1px)',
boxShadow: '0 14px 36px rgba(15,23,42,0.55)',
},
'&:disabled': {
background: 'rgba(148,163,184,0.4)',
color: 'rgba(15,23,42,0.6)',
boxShadow: 'none',
transform: 'none',
}
}}
>
Launch Alwrity & Complete Setup
</Button>
@@ -555,12 +545,16 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
{/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
This will complete your onboarding and launch Alwrity with your configured settings.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Star sx={{ fontSize: 16 }} />
Ready to create amazing content with AI-powered assistance
<Typography
variant="body2"
color="text.secondary"
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
>
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
Your SIF Agent Framework is ready to orchestrate your marketing.
</Typography>
</Box>
</React.Fragment>

View File

@@ -22,6 +22,7 @@ import {
Chip,
Stack,
Divider,
Tooltip,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import GroupIcon from "@mui/icons-material/Group";
@@ -30,6 +31,7 @@ import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import SaveIcon from "@mui/icons-material/Save";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
aiOptimizeAgentProfile,
@@ -242,18 +244,168 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
};
return (
<Paper sx={{ mt: 3, p: 3, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<GroupIcon />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Meet {websiteName || "Your"} AI Marketing Team
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
These agents work together to help you plan, execute, and improve your digital marketing. Tools and responsibilities are locked for safety and reliability.
</Typography>
<Paper
elevation={0}
sx={{
mt: 3,
p: 3,
borderRadius: 4,
border: "1px solid #e2e8f0",
bgcolor: "#ffffff",
color: "#0f172a",
boxShadow: "0 1px 2px rgba(15,23,42,0.04)",
"& .MuiTypography-root": {
color: "#111827 !important",
WebkitTextFillColor: "#111827",
},
"& .MuiTypography-body2": {
color: "#4b5563 !important",
},
"& .MuiTypography-caption": {
color: "#6b7280 !important",
},
"& .MuiFormLabel-root": {
color: "#4b5563 !important",
},
"& .MuiFormLabel-root.Mui-focused": {
color: "#4f46e5 !important",
},
"& .MuiInputBase-input": {
color: "#111827 !important",
},
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff !important",
color: "#111827 !important",
},
"& .MuiAccordionDetails-root": {
bgcolor: "#ffffff !important",
},
}}
>
<Box
sx={{
mb: 3,
p: 2.5,
borderRadius: 3,
background: "linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
boxShadow: "0 12px 30px rgba(15,23,42,0.45)",
"& .MuiTypography-root": {
color: "#e5e7eb !important",
WebkitTextFillColor: "#e5e7eb",
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "999px",
bgcolor: "rgba(129,140,248,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#e5e7eb",
}}
>
<GroupIcon />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 800, letterSpacing: 0.2 }}>
Meet {websiteName || "Your"} AI Marketing Team
</Typography>
<Typography variant="body2" sx={{ opacity: 0.92 }}>
Enterprise-grade autonomous agents orchestrated by ALwrity&apos;s SIF framework to run your marketing.
</Typography>
</Box>
</Box>
<Tooltip
title="Semantic Intelligence Framework™ Alwrity's orchestration layer for autonomous marketing agents."
arrow
placement="left"
>
<Chip
size="small"
label="SIF Agent Framework™"
sx={{
borderRadius: "999px",
border: "1px solid rgba(191,219,254,0.9)",
bgcolor: "rgba(15,23,42,0.75)",
color: "#e5e7eb",
fontWeight: 600,
letterSpacing: 0.4,
textTransform: "uppercase",
}}
/>
</Tooltip>
</Box>
<Stack spacing={1.5}>
<Box
sx={{
mb: 2,
px: 0.5,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexWrap: "wrap",
gap: 1.5,
}}
>
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.6 }}>
Agent roles
</Typography>
<Chip
size="small"
label="Lead"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#eef2ff",
color: "#312e81",
fontWeight: 600,
}}
/>
<Chip
size="small"
label="Strategist"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#ecfdf5",
color: "#047857",
fontWeight: 600,
}}
/>
<Chip
size="small"
label="Analyst"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#eff6ff",
color: "#1d4ed8",
fontWeight: 600,
}}
/>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: "999px", bgcolor: "#22c55e" }} />
<Typography variant="caption">Enabled</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: "999px", bgcolor: "#e5e7eb" }} />
<Typography variant="caption">Disabled</Typography>
</Stack>
</Stack>
</Box>
<Stack spacing={2}>
{agents.map((agent) => {
const displayName = resolveDisplayName(agent, websiteName);
const scheduleText = formatSchedule(agent.profile?.schedule ?? agent.defaults?.schedule);
@@ -261,66 +413,173 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
const warnings = draft ? lintDraft(agent, draft) : [];
return (
<Accordion key={agent.agent_key} disableGutters elevation={0} sx={{ borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)" }}>
<Accordion
key={agent.agent_key}
disableGutters
elevation={0}
sx={{
borderRadius: 2,
border: "1px solid #e2e8f0",
bgcolor: "#f9fafb",
"&:before": { display: "none" },
transition: "all 160ms ease",
"&:hover": {
borderColor: "#4f46e5",
boxShadow: "0 8px 24px rgba(15,23,42,0.12)",
transform: "translateY(-1px)",
},
"&.Mui-expanded": {
borderColor: "#4f46e5",
boxShadow: "0 12px 30px rgba(15,23,42,0.16)",
bgcolor: "#ffffff",
},
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 2 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, lineHeight: 1.2, color: "#0f172a" }}
noWrap
>
{displayName}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
<Typography
variant="body2"
sx={{ color: "#64748b" }}
noWrap
>
{agent.role || agent.agent_key} {scheduleText}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ flexShrink: 0 }}>
<Chip size="small" icon={<LockIcon />} label="Tools locked" variant="outlined" />
<Chip size="small" icon={<LockIcon />} label="Responsibilities locked" variant="outlined" />
<Tooltip title="System tools this agent can call while executing your strategy." arrow>
<Chip
size="small"
icon={<LockIcon />}
label="Tools locked"
variant="outlined"
sx={{
fontWeight: 500,
borderColor: "#cbd5e1",
bgcolor: "#e5edff",
color: "#1e293b",
}}
/>
</Tooltip>
<Tooltip title="High-level responsibilities are predefined for safety and reliability." arrow>
<Chip
size="small"
icon={<LockIcon />}
label="Responsibilities locked"
variant="outlined"
sx={{
fontWeight: 500,
borderColor: "#cbd5e1",
bgcolor: "#e5edff",
color: "#1e293b",
}}
/>
</Tooltip>
</Stack>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoFixHighIcon />}
disabled={aiBusyKey === agent.agent_key}
onClick={() => handleAiOptimize(agent)}
sx={{ textTransform: "none" }}
<Tooltip
title="Let ALwrity refine this agent's prompts and schedule based on your brand context."
arrow
>
AI Optimize
</Button>
<Button
size="small"
variant="outlined"
startIcon={<VisibilityIcon />}
disabled={previewBusyKey === agent.agent_key}
onClick={() => handlePreview(agent)}
sx={{ textTransform: "none" }}
<span>
<Button
size="small"
variant="outlined"
startIcon={<AutoFixHighIcon />}
disabled={aiBusyKey === agent.agent_key}
onClick={() => handleAiOptimize(agent)}
sx={{
textTransform: "none",
borderColor: "#4f46e5",
color: "#4f46e5",
"&:hover": {
borderColor: "#4338ca",
background: "rgba(79,70,229,0.04)",
},
}}
>
AI Optimize
</Button>
</span>
</Tooltip>
<Tooltip
title="Preview how this agent would respond using the current configuration."
arrow
>
Preview
</Button>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!draft || savingKey === agent.agent_key}
onClick={() => handleSave(agent)}
sx={{ textTransform: "none" }}
<span>
<Button
size="small"
variant="outlined"
startIcon={<VisibilityIcon />}
disabled={previewBusyKey === agent.agent_key}
onClick={() => handlePreview(agent)}
sx={{
textTransform: "none",
borderColor: "#0f172a",
color: "#0f172a",
"&:hover": {
borderColor: "#111827",
background: "rgba(15,23,42,0.04)",
},
}}
>
Preview
</Button>
</span>
</Tooltip>
<Tooltip
title="Persist this agent's configuration for future sessions."
arrow
>
Save
</Button>
<Button
size="small"
variant="text"
startIcon={<RestartAltIcon />}
disabled={savingKey === agent.agent_key}
onClick={() => handleReset(agent)}
sx={{ textTransform: "none" }}
<span>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!draft || savingKey === agent.agent_key}
onClick={() => handleSave(agent)}
sx={{
textTransform: "none",
background: "linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)",
boxShadow: "0 4px 12px rgba(79,70,229,0.35)",
"&:hover": {
background: "linear-gradient(135deg, #4338ca 0%, #6d28d9 100%)",
boxShadow: "0 6px 18px rgba(79,70,229,0.45)",
},
}}
>
Save
</Button>
</span>
</Tooltip>
<Tooltip
title="Revert this agent to its recommended default settings."
arrow
>
Reset
</Button>
<span>
<Button
size="small"
variant="text"
startIcon={<RestartAltIcon />}
disabled={savingKey === agent.agent_key}
onClick={() => handleReset(agent)}
sx={{ textTransform: "none" }}
>
Reset
</Button>
</span>
</Tooltip>
</Box>
{warnings.length > 0 && (
@@ -367,9 +626,34 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
<Divider />
{draft && (
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
<Box
sx={{
mt: 1,
p: 2.5,
borderRadius: 2,
border: "1px dashed #e5e7eb",
bgcolor: "#f9fafb",
}}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
mb: 1.5,
display: "flex",
alignItems: "center",
gap: 0.75,
}}
>
<EditOutlinedIcon sx={{ fontSize: 18, color: "#4f46e5" }} />
Editable settings
<Typography
component="span"
variant="caption"
sx={{ ml: 0.75, color: "#6b7280" }}
>
Adjust how this agent behaves for your workspace.
</Typography>
</Typography>
<Stack spacing={2}>
<TextField
@@ -377,6 +661,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
value={draft.display_name}
onChange={(e) => setDraftField(agent.agent_key, { display_name: e.target.value })}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
<FormControlLabel
control={
@@ -388,7 +683,20 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
label="Enabled"
/>
<FormControl fullWidth>
<FormControl
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
>
<InputLabel>Schedule</InputLabel>
<Select
label="Schedule"
@@ -418,12 +726,34 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
})
}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
<TextField
label="Time (HH:MM)"
value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
</Stack>
)}
@@ -434,6 +764,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
)}
@@ -444,6 +785,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
multiline
minRows={6}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
<TextField
label="Task prompt template"
@@ -452,6 +804,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
multiline
minRows={6}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/>
</Stack>
</Box>

View File

@@ -17,6 +17,10 @@ import {
Chip
} from '@mui/material';
import {
ArrowForward as ArrowForwardIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayArrowIcon,
// Social Media Icons
Facebook as FacebookIcon,
Twitter as TwitterIcon,
@@ -31,10 +35,13 @@ import {
Google as GoogleIcon,
Analytics as AnalyticsIcon,
// UI Icons
Psychology as PsychologyIcon,
AutoAwesome as AutoAwesomeIcon,
Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon
} from '@mui/icons-material';
import { motion } from 'framer-motion';
// Import refactored components
import EmailSection from './common/EmailSection';
@@ -53,6 +60,7 @@ interface IntegrationsStepProps {
onContinue: () => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
onDataChange?: (data: any) => void;
}
interface IntegrationPlatform {
@@ -68,7 +76,7 @@ interface IntegrationPlatform {
isEnabled: boolean;
}
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent, onValidationChange, onDataChange }) => {
const { user } = useUser();
const [email, setEmail] = useState<string>('');
@@ -102,7 +110,7 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
const { connected: wordpressConnected, sites: wordpressSites } = useWordPressOAuth();
// Bing OAuth hook
const { connected: bingConnected, sites: bingSites, connect: connectBing } = useBingOAuth();
const { connected: bingConnected, sites: bingSites, connect: connectBing, refreshStatus: refreshBingStatus } = useBingOAuth();
// Initialize integrations data
const [integrations] = useState<IntegrationPlatform[]>([
@@ -257,6 +265,17 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
}
}, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
useEffect(() => {
(async () => {
try {
await refreshBingStatus();
} catch (e) {
console.error('Failed to refresh Bing status:', e);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle Bing connection status changes
useEffect(() => {
@@ -354,6 +373,65 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
return sites;
}, [wixConnected, wixSites, wordpressConnected, wordpressSites]);
useEffect(() => {
if (!onDataChange) {
return;
}
const websiteIntegrations = {
wix: wixConnected ? wixSites.map(s => ({ url: s.blog_url, name: 'Wix Site' })) : [],
wordpress: wordpressConnected ? wordpressSites.map(s => ({ url: s.blog_url, name: 'WordPress Site' })) : [],
primaryWebsite: primarySite || null,
};
const analyticsIntegrations = {
gsc: {
connected: connectedPlatforms.includes('gsc'),
sites: (gscSites || []).map((site: any) => ({
siteUrl: site.siteUrl || site.site_url || '',
})),
},
bing: {
connected: connectedPlatforms.includes('bing') || !!bingConnected,
sites: (bingSites || []).map((site: any) => ({
siteUrl: site.siteUrl || site.site_url || '',
})),
},
};
const socialIntegrations = {
facebook: connectedPlatforms.includes('facebook'),
twitter: connectedPlatforms.includes('twitter'),
linkedin: connectedPlatforms.includes('linkedin'),
instagram: connectedPlatforms.includes('instagram'),
youtube: connectedPlatforms.includes('youtube'),
tiktok: connectedPlatforms.includes('tiktok'),
pinterest: connectedPlatforms.includes('pinterest'),
};
onDataChange({
integrations: {
primaryWebsite: websiteIntegrations.primaryWebsite,
websitePlatforms: websiteIntegrations,
analyticsPlatforms: analyticsIntegrations,
socialPlatforms: socialIntegrations,
connectedPlatforms,
updatedAt: new Date().toISOString(),
},
});
}, [
onDataChange,
primarySite,
wixConnected,
wixSites,
wordpressConnected,
wordpressSites,
gscSites,
bingConnected,
bingSites,
connectedPlatforms,
]);
// Default to first site
useEffect(() => {
if (availableSites.length > 0 && !primarySite) {
@@ -379,6 +457,30 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
}
}, [availableSites.length, primarySite, onValidationChange]);
const [walkthroughStep, setWalkthroughStep] = useState<number>(0);
const walkthroughTitles: string[] = [
'Connect your platforms',
'We cache your insights',
'Agents analyze weekly',
'We propose clear fixes',
'You review and publish',
];
const walkthroughDescriptions: string[] = [
'Link Google Search Console and Bing to unlock search signals for your site.',
'We safely store key metrics so recommendations are quick and quotafriendly.',
'SIF agents look for lowCTR pages, strikingdistance wins, declines, and overlaps.',
'Youll see simple suggestions: better titles/meta, refreshes, and consolidations.',
'Pick what you like and publish; we keep the rhythm going week after week.',
];
const walkthroughLabels: string[] = ['Step 1 of 5', 'Step 2 of 5', 'Step 3 of 5', 'Step 4 of 5', 'Step 5 of 5'];
useEffect(() => {
const id = setInterval(() => {
setWalkthroughStep(prev => (prev + 1) % walkthroughTitles.length);
}, 4500);
return () => clearInterval(id);
}, [walkthroughTitles.length]);
return (
<Box sx={{ width: '100%', maxWidth: '100%', p: { xs: 1, sm: 2, md: 3 } }}>
{/* Email Address Section */}
@@ -594,6 +696,281 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
{/* Coming Soon Section */}
<ComingSoonSection />
{/* Recommendation Panel */}
<Fade in timeout={1500}>
<div>
<Paper
elevation={2}
sx={{
mt: 2.5,
p: { xs: 2, md: 2.5 },
borderRadius: 2,
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
border: '1px solid #e2e8f0'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<AutoAwesomeIcon sx={{ color: '#7c3aed' }} />
<Typography variant="h6" sx={{ fontWeight: 700, color: '#111827' }}>
How ALwritys SIF Agents Help You Every Week
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#334155', mb: 1.5 }}>
Your connected analytics power a helpful weekly routine. Our SIF agent framework reads real search signals and proposes simple, highimpact actions for your content—no jargon, just clear next steps.
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
<Chip icon={<AnalyticsIcon />} label="LowCTR pages" sx={{ bgcolor: '#eef2ff', color: '#312e81', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Strikingdistance wins" sx={{ bgcolor: '#ecfeff', color: '#075985', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Declining queries" sx={{ bgcolor: '#f0fdf4', color: '#14532d', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Cannibalization fixes" sx={{ bgcolor: '#fff7ed', color: '#7c2d12', fontWeight: 600 }} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
GSC & Bing Metrics
</Typography>
<AnalyticsIcon sx={{ color: '#2563eb' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Clicks, impressions, CTR, positions
</Typography>
</Paper>
</motion.div>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.05 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
SIF Agents
</Typography>
<PsychologyIcon sx={{ color: '#7c3aed' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Turns signals into clear suggestions
</Typography>
</Paper>
</motion.div>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: 0.1 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
Suggested Actions
</Typography>
<AutoAwesomeIcon sx={{ color: '#059669' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Better titles/meta, refreshes, consolidations
</Typography>
</Paper>
</motion.div>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
<motion.div initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.45 }}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Who does what
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip size="small" label="SEO Agent" sx={{ bgcolor: '#eef2ff', color: '#312e81', fontWeight: 700 }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Finds lowCTR pages and strikingdistance queries; suggests title/meta fixes and refreshes.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip size="small" label="Content Agent" sx={{ bgcolor: '#ecfeff', color: '#075985', fontWeight: 700 }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Recommends consolidation and internal links from cannibalization; queues refresh topics.
</Typography>
</Box>
</Paper>
</motion.div>
<motion.div initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.45 }}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
What you get
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Clear, bitesize fixes that improve visibility and clicks.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
A weekly rhythm that keeps content fresh and organized.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Caching protects your quota; agents use cached insights, not direct API calls.
</Typography>
</Box>
</Paper>
</motion.div>
</Box>
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Full Flow at a Glance
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap' }}>
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
1. Connect
</Typography>
<AnalyticsIcon sx={{ color: '#2563eb' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
GSC & Bing
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
2. Cache
</Typography>
<AutoAwesomeIcon sx={{ color: '#0891b2' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
Fast, quotasafe
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
3. Analyze
</Typography>
<PsychologyIcon sx={{ color: '#7c3aed' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
SIF agents
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
4. Suggest
</Typography>
<AutoAwesomeIcon sx={{ color: '#059669' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
Clear fixes
</Typography>
</Paper>
</Box>
</Paper>
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Guided Walkthrough
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 1.5 }}>
<Chip
icon={<PlayArrowIcon />}
label="Auto walkthrough"
sx={{ bgcolor: '#eef2ff', color: '#111827', fontWeight: 700 }}
/>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600 }}>
{walkthroughLabels[walkthroughStep]}
</Typography>
</Box>
<Box sx={{ position: 'relative', minHeight: 120 }}>
<motion.div
key={`walk-${walkthroughStep}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.35 }}
>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px dashed #cbd5e1', bgcolor: '#f8fafc' }}>
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 600, mb: 0.5 }}>
{walkthroughTitles[walkthroughStep]}
</Typography>
<Typography variant="body2" sx={{ color: '#475569' }}>
{walkthroughDescriptions[walkthroughStep]}
</Typography>
</Paper>
</motion.div>
</Box>
</Paper>
</Box>
</Box>
</Paper>
</div>
</Fade>
{/* Success Toast */}
<Snackbar
open={showToast}
@@ -613,4 +990,4 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
);
};
export default IntegrationsStep;
export default IntegrationsStep;

View File

@@ -67,7 +67,7 @@ interface QualityMetrics {
type PersonalizationTab = 'text' | 'image' | 'audio';
const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
onContinue,
onContinue: _onContinue,
updateHeaderContent,
onValidationChange,
onDataChange,
@@ -80,7 +80,6 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
// AI Generation state (Ported from PersonaStep)
const [generationStep, setGenerationStep] = useState<string>('analyzing');
const [isGenerating, setIsGenerating] = useState(false);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -94,7 +93,7 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
// UI state
const [showPreview, setShowPreview] = useState(false);
const [expandedAccordion, setExpandedAccordion] = useState<string | false>('core');
const [hasCheckedCache, setHasCheckedCache] = useState(false);
const [, setHasCheckedCache] = useState(false);
const [configurationOptions, setConfigurationOptions] = useState<any>(null);
// Asset Status State
@@ -417,26 +416,6 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
generatePersonas();
};
const handleContinue = useCallback(() => {
if (corePersona && platformPersonas && qualityMetrics) {
if (!brandAvatarSet || !voiceCloneSet) {
setError('Please generate and set your Brand Avatar and Voice Clone before continuing.');
return;
}
const personaData = {
corePersona,
platformPersonas,
qualityMetrics,
selectedPlatforms,
stepType: 'personalization',
completedAt: new Date().toISOString()
};
onContinue(personaData);
} else {
setError('Missing persona data. Please generate your brand voice first.');
}
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue, brandAvatarSet, voiceCloneSet]);
useEffect(() => {
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview' && brandAvatarSet && voiceCloneSet;

View File

@@ -9,6 +9,7 @@ import {
} from '@mui/material';
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
import { fetchMediaBlobUrl } from '../../../../utils/fetchMediaBlobUrl';
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
import { OperationButton } from '../../../shared/OperationButton';
@@ -29,6 +30,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
const [success, setSuccess] = useState<string | null>(null);
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
const [showCapabilities, setShowCapabilities] = useState(false);
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const STORAGE_KEY = 'test_persona_video_url';
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
@@ -135,9 +137,29 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
setGeneratedVideoUrl(null);
try {
// 1. Fetch blobs from URLs (works for data URIs too)
const avatarBlob = await fetch(avatarUrl).then(r => r.blob());
const voiceBlob = await fetch(voiceUrl).then(r => r.blob());
let avatarBlob: Blob;
try {
const avatarBlobUrl = await fetchMediaBlobUrl(avatarUrl);
if (avatarBlobUrl) {
avatarBlob = await fetch(avatarBlobUrl).then(r => r.blob());
} else {
avatarBlob = await fetch(avatarUrl).then(r => r.blob());
}
} catch {
avatarBlob = await fetch(avatarUrl).then(r => r.blob());
}
let voiceBlob: Blob;
try {
const voiceBlobUrl = await fetchMediaBlobUrl(voiceUrl);
if (voiceBlobUrl) {
voiceBlob = await fetch(voiceBlobUrl).then(r => r.blob());
} else {
voiceBlob = await fetch(voiceUrl).then(r => r.blob());
}
} catch {
voiceBlob = await fetch(voiceUrl).then(r => r.blob());
}
// 2. Create Files
const avatarFile = new File([avatarBlob], "avatar.png", { type: avatarBlob.type });
@@ -175,6 +197,68 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
}, 100);
};
useEffect(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
return;
}
if (avatarUrl.startsWith('data:') || avatarUrl.startsWith('blob:')) {
setAvatarBlobUrl(null);
return;
}
const isInternal =
avatarUrl.includes('/api/podcast/') ||
avatarUrl.includes('/api/youtube/') ||
avatarUrl.includes('/api/story/') ||
(avatarUrl.startsWith('/') && !avatarUrl.startsWith('//'));
if (!isInternal) {
setAvatarBlobUrl(null);
return;
}
let isMounted = true;
const currentAvatarUrl = avatarUrl;
const loadAvatarBlob = async () => {
try {
const blobUrl = await fetchMediaBlobUrl(currentAvatarUrl);
if (!isMounted || avatarUrl !== currentAvatarUrl) {
if (blobUrl && blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
return;
}
setAvatarBlobUrl(prev => {
if (prev && prev !== blobUrl && prev.startsWith('blob:')) {
URL.revokeObjectURL(prev);
}
return blobUrl;
});
} catch {
if (isMounted && avatarUrl === currentAvatarUrl) {
setAvatarBlobUrl(null);
}
}
};
loadAvatarBlob();
return () => {
isMounted = false;
setAvatarBlobUrl(prev => {
if (prev && prev.startsWith('blob:')) {
URL.revokeObjectURL(prev);
}
return null;
});
};
}, [avatarUrl]);
const CapabilitiesModal = () => (
<Dialog
open={showCapabilities}
@@ -429,7 +513,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
{/* Avatar Preview */}
<Box sx={{ position: 'relative' }}>
<Avatar
src={avatarUrl}
src={avatarBlobUrl || avatarUrl}
sx={{ width: 140, height: 140, border: '4px solid #ffffff', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Box sx={{ position: 'absolute', bottom: 0, right: 0, bgcolor: '#10b981', color: 'white', p: 0.5, borderRadius: '50%', border: '2px solid white' }}>

View File

@@ -29,6 +29,7 @@ import {
// Extracted components
import { AnalysisResultsDisplay, AnalysisProgressDisplay } from './WebsiteStep/components';
import type { StyleAnalysis } from './WebsiteStep/components/AnalysisResultsDisplay';
// Import API client for saving
import { apiClient } from '../../api/client';
@@ -48,104 +49,6 @@ interface WebsiteStepProps {
onValidationChange?: (isValid: boolean) => void;
}
interface StyleAnalysis {
id?: number;
writing_style?: {
tone: string;
voice: string;
complexity: string;
engagement_level: string;
brand_personality?: string;
formality_level?: string;
emotional_appeal?: string;
};
content_characteristics?: {
sentence_structure: string;
vocabulary_level: string;
paragraph_organization: string;
content_flow: string;
readability_score?: string;
content_density?: string;
visual_elements_usage?: string;
};
target_audience?: {
demographics: string[];
expertise_level: string;
industry_focus: string;
geographic_focus: string;
psychographic_profile?: string;
pain_points?: string[];
motivations?: string[];
};
content_type?: {
primary_type: string;
secondary_types: string[];
purpose: string;
call_to_action: string;
conversion_focus?: string;
educational_value?: string;
};
brand_analysis?: {
brand_voice: string;
brand_values: string[];
brand_positioning: string;
competitive_differentiation: string;
trust_signals: string[];
authority_indicators: string[];
};
content_strategy_insights?: {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
recommended_improvements: string[];
content_gaps: string[];
};
recommended_settings?: {
writing_tone: string;
target_audience: string;
content_type: string;
creativity_level: string;
geographic_location: string;
industry_context?: string;
brand_alignment?: string;
};
guidelines?: {
tone_recommendations: string[];
structure_guidelines: string[];
vocabulary_suggestions: string[];
engagement_tips: string[];
audience_considerations: string[];
brand_alignment?: string[];
seo_optimization?: string[];
conversion_optimization?: string[];
};
best_practices?: string[];
avoid_elements?: string[];
content_strategy?: string;
ai_generation_tips?: string[];
competitive_advantages?: string[];
content_calendar_suggestions?: string[];
style_patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
style_consistency?: string;
unique_elements?: string[];
seo_audit?: any;
sitemap_analysis?: any;
}
interface AnalysisProgress {
step: number;
message: string;
@@ -189,6 +92,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [analysisWarning, setAnalysisWarning] = useState<string | null>(null);
const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null);
const [crawlResult, setCrawlResult] = useState<any>(null);
const [existingAnalysis, setExistingAnalysis] = useState<ExistingAnalysis | null>(null);
@@ -290,6 +194,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setDomainName(result.domainName || '');
setAnalysis(result.analysis);
setCrawlResult(result.crawlResult);
setAnalysisWarning(result.warning || null);
setSuccess('Loaded previous analysis successfully!');
}
return result;
@@ -298,6 +203,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
const handleAnalyze = async () => {
setError(null);
setSuccess(null);
setAnalysisWarning(null);
setLoading(true);
setAnalysis(null);
setCrawlResult(null);
@@ -330,6 +236,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setDomainName(analysisResult.domainName || '');
setAnalysis(analysisResult.analysis);
setCrawlResult(analysisResult.crawlResult);
setAnalysisWarning(analysisResult.warning || null);
// Store in localStorage for Step 3 (Competitor Analysis)
localStorage.setItem('website_url', fixedUrl);
@@ -404,6 +311,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
if (analysisResult.success) {
setDomainName(analysisResult.domainName || '');
setAnalysis(analysisResult.analysis);
setAnalysisWarning(analysisResult.warning || null);
if (analysisResult.warning) {
setSuccess(`Website style analysis completed successfully! Note: ${analysisResult.warning}`);
@@ -754,6 +662,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
useAnalysisForGenAI={useAnalysisForGenAI}
onUseAnalysisChange={setUseAnalysisForGenAI}
onAnalysisUpdate={handleAnalysisUpdate}
warning={analysisWarning || undefined}
onSave={() => saveAnalysis(analysis)}
/>
</Box>

View File

@@ -64,7 +64,18 @@ import { useOnboardingStyles } from '../../common/useOnboardingStyles';
import { apiClient } from '../../../../api/client';
interface StyleAnalysis {
export interface StyleAnalysis {
id?: number;
guidelines?: {
tone_recommendations?: string[];
structure_guidelines?: string[];
vocabulary_suggestions?: string[];
engagement_tips?: string[];
audience_considerations?: string[];
brand_alignment?: string[];
seo_optimization?: string[];
conversion_optimization?: string[];
} | null;
writing_style?: {
tone: string;
voice: string;
@@ -132,6 +143,7 @@ interface AnalysisResultsDisplayProps {
onUseAnalysisChange: (use: boolean) => void;
crawlResult?: any;
onAnalysisUpdate?: (updatedAnalysis: StyleAnalysis) => void;
warning?: string;
onSave?: () => void;
}
@@ -142,12 +154,17 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
onUseAnalysisChange,
crawlResult,
onAnalysisUpdate,
warning,
onSave
}) => {
const styles = useOnboardingStyles();
const [isCrawlExpanded, setIsCrawlExpanded] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const warningParts = warning ? warning.split('|').map(part => part.trim()).filter(Boolean) : [];
const guidelineWarning = warningParts.find(part => part.toLowerCase().startsWith('guidelines generation failed'));
const sitemapWarning = warningParts.find(part => part.toLowerCase().startsWith('sitemap analysis failed'));
// Helper to handle section updates
const handleSectionUpdate = (section: string, fieldPath: string, value: any) => {
if (!onAnalysisUpdate) return;
@@ -383,17 +400,25 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
{renderBrandAnalysisSection(analysis)}
</Box>
{/* Style Guidelines Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Style Guidelines"
icon={<AutoAwesomeIcon />}
/>
<EnhancedGuidelinesSection
guidelines={analysis.style_guidelines}
domainName={domainName}
/>
</Box>
{(analysis.guidelines || guidelineWarning) && (
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Style Guidelines"
icon={<AutoAwesomeIcon />}
/>
{guidelineWarning && (
<Alert severity="warning" sx={{ mb: 2 }}>
{guidelineWarning}
</Alert>
)}
{analysis.guidelines && (
<EnhancedGuidelinesSection
guidelines={analysis.guidelines}
domainName={domainName}
/>
)}
</Box>
)}
{/* SEO Audit Section */}
<Box sx={{ mt: 4 }}>
@@ -408,12 +433,16 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
/>
</Box>
{/* Sitemap Analysis Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Sitemap Analysis"
icon={<LinkIcon />}
/>
{sitemapWarning && (
<Alert severity="warning" sx={{ mb: 2 }}>
{sitemapWarning}
</Alert>
)}
<SitemapAnalysisSection
sitemapAnalysis={analysis.sitemap_analysis}
domainName={domainName}

View File

@@ -36,7 +36,7 @@ interface Guidelines {
}
interface EnhancedGuidelinesSectionProps {
guidelines: Guidelines;
guidelines?: Guidelines | null;
domainName: string;
}
@@ -46,6 +46,10 @@ const EnhancedGuidelinesSection: React.FC<EnhancedGuidelinesSectionProps> = ({
}) => {
const styles = useOnboardingStyles();
if (!guidelines) {
return null;
}
return (
<Box sx={styles.analysisSection}>
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>

View File

@@ -108,6 +108,7 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
analysis?: any;
domainName?: string;
crawlResult?: any;
warning?: string;
error?: string;
}> => {
try {
@@ -115,13 +116,12 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
const result = response.data;
if (result.success && result.analysis) {
// Extract domain name for personalization
const extractedDomain = extractDomainName(website);
// Database structure: flat fields at top level
// Need to combine them into the format expected by UI
const comprehensiveAnalysis = {
// Top-level style analysis fields from database
id: result.analysis.id,
writing_style: result.analysis.writing_style,
content_characteristics: result.analysis.content_characteristics,
target_audience: result.analysis.target_audience,
@@ -151,7 +151,8 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
success: true,
analysis: comprehensiveAnalysis,
domainName: extractedDomain,
crawlResult: result.analysis.crawl_result
crawlResult: result.analysis.crawl_result,
warning: result.analysis.warning_message
};
}
return {
@@ -212,6 +213,7 @@ export const performAnalysis = async (
// Combine all analysis data into a comprehensive object
const comprehensiveAnalysis = {
id: result.analysis_id,
...result.style_analysis,
seo_audit: result.seo_audit,
sitemap_analysis: result.crawl_result?.sitemap_analysis,

View File

@@ -654,6 +654,20 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
}
}
// Special handling for IntegrationsStep (step 4)
if (activeStep === 4) {
const currentData = stepDataRef.current || {};
if (!currentStepData && currentData && typeof currentData === 'object') {
if (currentData.integrations) {
currentStepData = {
integrations: currentData.integrations,
};
} else {
currentStepData = currentData;
}
}
}
// Store step data in state
if (currentStepData) {
setStepData(currentStepData);
@@ -681,7 +695,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
// Complete the current step (activeStep + 1 because steps are 1-indexed)
const currentStepNumber = activeStep + 1;
const stepWasCompleted = currentStepData && typeof currentStepData === 'object' && (
const hasCoreStepData = currentStepData && typeof currentStepData === 'object' && (
currentStepData.website ||
currentStepData.businessData ||
currentStepData.competitors ||
@@ -692,6 +706,10 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
currentStepData.qualityMetrics
);
const hasIntegrationsData = !!(currentStepData && typeof currentStepData === 'object' && currentStepData.integrations);
const stepWasCompleted = hasCoreStepData || hasIntegrationsData;
console.log('Wizard: Step completion check:', {
currentStepNumber,
hasData: !!currentStepData,
@@ -881,6 +899,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
onContinue={handleNext}
updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid: boolean) => handleStepValidationChange(4, isValid)}
onDataChange={handleStepDataChange}
/>,
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
];
@@ -901,6 +920,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
return (
<Box
className="light-theme-container"
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',

View File

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

View 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

View File

@@ -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>
);
};

View File

@@ -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" : "Well start AI analysis after this click"}
>
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
</PrimaryButton>
</Stack>
</Stack>
);
};

View 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 conciseone 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

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

View File

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

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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)" }} />

View File

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

View File

@@ -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,

View File

@@ -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)}

View File

@@ -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>
);
};

View File

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

View File

@@ -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 = {

View File

@@ -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,

View File

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

View File

@@ -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 = {

View File

@@ -3,24 +3,12 @@ import {
Box,
Container,
Typography,
Card,
CardContent,
CardActions,
Button,
Grid,
Chip,
List,
ListItem,
ListItemIcon,
ListItemText,
Switch,
FormControlLabel,
Divider,
Alert,
CircularProgress,
useTheme,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
@@ -30,34 +18,13 @@ import {
Backdrop,
Snackbar,
} from '@mui/material';
import {
Check as CheckIcon,
Close as CloseIcon,
Star as StarIcon,
WorkspacePremium as PremiumIcon,
Info as InfoIcon,
Warning,
Psychology,
Search as SearchIcon,
FactCheck,
Edit,
Assistant,
Verified,
Timeline,
Analytics,
Support,
Business,
Group,
} from '@mui/icons-material';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import ImageIcon from '@mui/icons-material/Image';
import VideoIcon from '@mui/icons-material/VideoLibrary';
import AudioIcon from '@mui/icons-material/Audiotrack';
import { Warning } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../../api/client';
import { restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
import PlanCard from './PricingPage/PlanCard';
interface SubscriptionPlan {
export interface SubscriptionPlan {
id: number;
name: string;
tier: string;
@@ -85,7 +52,6 @@ interface SubscriptionPlan {
}
const PricingPage: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [loading, setLoading] = useState(true);
@@ -198,11 +164,32 @@ const PricingPage: React.FC = () => {
return;
}
// Get selected plan details
const plan = plans.find(p => p.id === selectedPlan);
if (!plan) return;
try {
setSubscribing(true);
const userId = localStorage.getItem('user_id') || 'anonymous';
console.log('[PricingPage] Making subscription API call:', {
// Check if Stripe is configured
if (process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY) {
console.log('[PricingPage] Initiating Stripe Checkout');
const response = await apiClient.post('/api/subscription/create-checkout-session', {
tier: plan.tier,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
success_url: `${window.location.origin}/dashboard?subscription=success`,
cancel_url: `${window.location.origin}/pricing?subscription=cancel`,
});
if (response.data.url) {
window.location.href = response.data.url;
return;
}
}
console.log('[PricingPage] Making legacy subscription API call:', {
url: `/api/subscription/subscribe/${userId}`,
plan_id: selectedPlan,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
@@ -299,36 +286,6 @@ const PricingPage: React.FC = () => {
});
};
const getPlanIcon = (tier: string) => {
switch (tier) {
case 'free':
return <CheckIcon color="success" />;
case 'basic':
return <StarIcon color="primary" />;
case 'pro':
return <PremiumIcon color="secondary" />;
case 'enterprise':
return <PremiumIcon sx={{ color: theme.palette.warning.main }} />;
default:
return <CheckIcon />;
}
};
const getPlanColor = (tier: string) => {
switch (tier) {
case 'free':
return 'success' as const;
case 'basic':
return 'primary' as const;
case 'pro':
return 'secondary' as const;
case 'enterprise':
return 'warning' as const;
default:
return undefined;
}
};
if (loading) {
return (
<Container maxWidth="lg" sx={{ py: 8, textAlign: 'center' }}>
@@ -389,769 +346,15 @@ const PricingPage: React.FC = () => {
<Grid container spacing={4} justifyContent="center">
{plans.map((plan) => (
<Grid item key={plan.id} xs={12} sm={6} md={3}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: selectedPlan === plan.id ? `2px solid ${theme.palette.primary.main}` : '1px solid #e0e0e0',
transform: selectedPlan === plan.id ? 'scale(1.02)' : 'scale(1)',
transition: 'all 0.3s ease-in-out',
}}
>
{/* Plan Badge */}
{plan.tier === 'pro' && (
<Chip
label="Most Popular"
color="primary"
size="small"
sx={{
position: 'absolute',
top: -8,
right: 16,
zIndex: 1,
}}
/>
)}
<CardContent sx={{ flexGrow: 1, textAlign: 'center' }}>
<Box sx={{ mb: 2 }}>
{getPlanIcon(plan.tier)}
</Box>
<Typography variant="h5" component="h2" gutterBottom>
{plan.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{plan.description}
</Typography>
{/* Pricing */}
<Box sx={{ mb: 3 }}>
<Typography variant="h3" component="span">
${yearlyBilling ? plan.price_yearly : plan.price_monthly}
</Typography>
<Typography variant="body2" color="text.secondary">
/{yearlyBilling ? 'year' : 'month'}
</Typography>
{yearlyBilling && (
<Typography variant="caption" color="success.main" sx={{ display: 'block' }}>
Save ${(plan.price_monthly * 12 - plan.price_yearly).toFixed(0)} yearly
</Typography>
)}
</Box>
{/* Features */}
<List dense>
{/* All Tools Access - Free & Basic */}
{(plan.tier === 'free' || plan.tier === 'basic') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
✨ All ALwrity Tools Included:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Blog Writer"
secondary="AI-powered blog post creation with SEO optimization"
/>
<Tooltip title="Learn more about Blog Writer">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Blog Writer', (
<Box>
<Typography variant="h6" gutterBottom>Blog Writer</Typography>
<Typography variant="body2" paragraph>
Create engaging blog posts with AI assistance. Includes SEO optimization,
keyword research, and content structure suggestions.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• SEO-optimized content generation</Typography>
<Typography variant="body2">• Keyword research integration</Typography>
<Typography variant="body2">• Content structure suggestions</Typography>
<Typography variant="body2">• Publishing assistance</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="LinkedIn Writer"
secondary="Professional LinkedIn content creation and posting"
/>
<Tooltip title="Learn more about LinkedIn Writer">
<IconButton
size="small"
onClick={() => openKnowMoreModal('LinkedIn Writer', (
<Box>
<Typography variant="h6" gutterBottom>LinkedIn Writer</Typography>
<Typography variant="body2" paragraph>
Create professional LinkedIn posts, articles, and carousels that engage
your network and showcase your expertise.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• Professional post generation</Typography>
<Typography variant="body2">• Article writing assistance</Typography>
<Typography variant="body2">• Carousel creation</Typography>
<Typography variant="body2">• Network engagement optimization</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Group color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Facebook Writer"
secondary="Engaging Facebook posts and content creation"
/>
<Tooltip title="Learn more about Facebook Writer">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Facebook Writer', (
<Box>
<Typography variant="h6" gutterBottom>Facebook Writer</Typography>
<Typography variant="body2" paragraph>
Create engaging Facebook posts, stories, and reels that drive
engagement and grow your community.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• Post and story creation</Typography>
<Typography variant="body2">• Reel script generation</Typography>
<Typography variant="body2">• Community management</Typography>
<Typography variant="body2">• Engagement optimization</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<MenuBookIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Story Writer"
secondary="Create stories with AI: outline, images, narration, and video"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Podcast Maker"
secondary="AI-powered research, scriptwriting, and voice narration"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Image Generator & Editor"
secondary="AI image creation and editing (background removal, inpainting)"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Studio & YouTube Creator"
secondary="AI video creation for social media and YouTube"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="All SEO Tools & Dashboards"
secondary="Keyword research, content optimization, SEO analytics"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Timeline color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Content Planning & Strategy"
secondary="Content calendars, strategy planning, and analytics"
/>
</ListItem>
</>
)}
{/* Platform Integrations - Pro & Free */}
{(plan.tier === 'free' || plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Platform Integrations:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Wix Integration"
secondary="Direct publishing to Wix websites"
/>
<Tooltip title="Learn more about Wix integration">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Wix Integration', (
<Box>
<Typography variant="h6" gutterBottom>Wix Integration</Typography>
<Typography variant="body2" paragraph>
Seamlessly publish your content directly to Wix websites.
No manual copying required.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• Direct blog post publishing</Typography>
<Typography variant="body2">• SEO metadata sync</Typography>
<Typography variant="body2">• Image optimization</Typography>
<Typography variant="body2">• Publishing queue management</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="WordPress Integration"
secondary="Publish to WordPress sites with API integration"
/>
<Tooltip title="Learn more about WordPress integration">
<IconButton
size="small"
onClick={() => openKnowMoreModal('WordPress Integration', (
<Box>
<Typography variant="h6" gutterBottom>WordPress Integration</Typography>
<Typography variant="body2" paragraph>
Connect directly to WordPress sites for seamless content publishing.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• REST API integration</Typography>
<Typography variant="body2">• Draft and publish modes</Typography>
<Typography variant="body2">• Category and tag management</Typography>
<Typography variant="body2">• Featured image handling</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Analytics color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Google Search Console"
secondary="SEO performance tracking and insights"
/>
<Tooltip title="Learn more about GSC integration">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Google Search Console', (
<Box>
<Typography variant="h6" gutterBottom>Google Search Console</Typography>
<Typography variant="body2" paragraph>
Monitor your website's SEO performance and get actionable insights
for content optimization.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> Search performance tracking</Typography>
<Typography variant="body2"> Keyword ranking insights</Typography>
<Typography variant="body2"> Technical SEO monitoring</Typography>
<Typography variant="body2"> Content optimization suggestions</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
</>
)}
{/* Social Media & Website Management - Pro & Enterprise */}
{(plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Social Media & Website Management:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Group color="secondary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="6 Major Social Platforms"
secondary="LinkedIn, Facebook, Instagram, Twitter, TikTok, YouTube"
/>
<Tooltip title="Learn more about social media platforms">
<IconButton
size="small"
onClick={() => openKnowMoreModal('6 Major Social Platforms', (
<Box>
<Typography variant="h6" gutterBottom>6 Major Social Platforms</Typography>
<Typography variant="body2" paragraph>
Comprehensive social media management across all major platforms
with AI-powered content optimization.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Platforms:</strong>
</Typography>
<Typography variant="body2"> LinkedIn (Professional networking)</Typography>
<Typography variant="body2"> Facebook (Community building)</Typography>
<Typography variant="body2"> Instagram (Visual storytelling)</Typography>
<Typography variant="body2"> Twitter (Real-time engagement)</Typography>
<Typography variant="body2"> TikTok (Short-form video)</Typography>
<Typography variant="body2"> YouTube (Long-form video content)</Typography>
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="secondary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Website Management"
secondary="Blogging platform with content scheduling and SEO tools"
/>
</ListItem>
</>
)}
{/* AI Content Creation Capabilities */}
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
AI Content Creation:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Text Generation"
secondary={plan.tier === 'free' || plan.tier === 'basic'
? "AI-powered text content creation"
: "Advanced text generation with multimodal capabilities"
}
/>
<Tooltip title="Learn more about text generation">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Text Generation', (
<Box>
<Typography variant="h6" gutterBottom>AI Text Generation</Typography>
<Typography variant="body2" paragraph>
Generate high-quality text content with AI assistance. From blog posts
to social media updates, create engaging content effortlessly.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Capabilities:</strong>
</Typography>
<Typography variant="body2"> Blog posts and articles</Typography>
<Typography variant="body2"> Social media content</Typography>
<Typography variant="body2"> Email newsletters</Typography>
<Typography variant="body2"> Marketing copy</Typography>
{plan.tier === 'pro' || plan.tier === 'enterprise' && (
<>
<Typography variant="body2"> Audio transcription</Typography>
<Typography variant="body2"> Video script writing</Typography>
</>
)}
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Assistant color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Image Generation"
secondary={plan.tier === 'free' || plan.tier === 'basic'
? "AI image creation for visual content"
: "Advanced image generation with video capabilities"
}
/>
<Tooltip title="Learn more about image generation">
<IconButton
size="small"
onClick={() => openKnowMoreModal('Image Generation', (
<Box>
<Typography variant="h6" gutterBottom>AI Image Generation</Typography>
<Typography variant="body2" paragraph>
Create stunning visuals with AI-powered image generation.
Perfect for social media, blog posts, and marketing materials.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Capabilities:</strong>
</Typography>
<Typography variant="body2"> Social media graphics</Typography>
<Typography variant="body2"> Blog featured images</Typography>
<Typography variant="body2"> Marketing visuals</Typography>
<Typography variant="body2"> Custom illustrations</Typography>
{plan.tier === 'pro' || plan.tier === 'enterprise' && (
<>
<Typography variant="body2"> Video thumbnail generation</Typography>
<Typography variant="body2"> Animated graphics</Typography>
</>
)}
</Box>
))}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
{/* Audio/Video for Basic, Pro & Enterprise */}
{(plan.tier === 'basic' || plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Audio Generation"
secondary={plan.tier === 'basic'
? "AI voice synthesis for podcasts, stories, and narration"
: "AI-powered audio content creation and voice synthesis"
}
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Generation"
secondary={plan.tier === 'basic'
? "Create AI videos for YouTube, social media, and stories"
: "AI video creation with script writing and editing"
}
/>
</ListItem>
</>
)}
{/* Advanced Features for Higher Tiers */}
{plan.tier !== 'free' && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Support & Analytics:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Support color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Priority Support" />
</ListItem>
{plan.tier === 'pro' && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Analytics color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Advanced Analytics & Insights" />
</ListItem>
)}
{plan.tier === 'enterprise' && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Custom Integrations" />
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Support color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Dedicated Account Manager" />
</ListItem>
</>
)}
</>
)}
{/* Usage Limits - User-Friendly Display */}
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
Monthly Usage Limits:
</Typography>
{/* For Basic tier, show unified AI text generation limit */}
{plan.tier === 'basic' && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Psychology color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="50 AI Text Generations"
secondary="~16-25 blog posts or ~25-50 social posts per month"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: 500 } }}
/>
</ListItem>
)}
{/* For other tiers, show provider-specific limits */}
{plan.tier !== 'basic' && (
<>
{plan.limits.gemini_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.gemini_calls === 0 ? '∞' : plan.limits.gemini_calls} Gemini AI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.openai_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.openai_calls === 0 ? '∞' : plan.limits.openai_calls} OpenAI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
</>
)}
{/* Image Generation */}
{plan.limits.stability_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.stability_calls} AI Images`}
secondary={plan.tier === 'basic' ? "Powered by open-source models (25% cost savings)" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Image Editing */}
{(plan.limits.image_edit_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.image_edit_calls ?? 0} Image Edits`}
secondary={plan.tier === 'basic' ? "Background removal, inpainting, recolor (50% cost savings with OSS)" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Video Generation */}
{(plan.limits.video_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.video_calls ?? 0} AI Videos`}
secondary={plan.tier === 'basic' ? "~5-6 full video projects (5 scenes each) per month" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Audio Generation */}
{(plan.limits.audio_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.audio_calls ?? 0} Audio Generations`}
secondary={plan.tier === 'basic' ? "Podcast narration, story audio, voice synthesis" : undefined}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: plan.tier === 'basic' ? 500 : 400 } }}
/>
</ListItem>
)}
{/* Research Queries */}
{plan.limits.tavily_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.tavily_calls} Research Searches`}
secondary="Web research, fact-checking, content discovery"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{/* Cost Cap Protection */}
{plan.limits.monthly_cost > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Verified color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`$${plan.limits.monthly_cost} Monthly Cost Cap`}
secondary="Automatic protection - you'll never exceed this amount"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: 500, color: 'success.main' } }}
/>
</ListItem>
)}
{/* OSS Model Notice for Basic Tier */}
{plan.tier === 'basic' && (
<Box sx={{ mt: 1, p: 1.5, bgcolor: 'info.lighter', borderRadius: 1, mx: 2 }}>
<Typography variant="caption" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, fontWeight: 500 }}>
<StarIcon fontSize="small" sx={{ color: 'info.main' }} />
Powered by Open-Source AI Models
</Typography>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
We use cost-effective open-source models to give you more value. 25-50% savings vs proprietary models.
</Typography>
</Box>
)}
</List>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3, flexDirection: 'column', gap: 1 }}>
{/* For alpha testing: Only Free and Basic are selectable, Pro/Enterprise disabled */}
{plan.tier === 'pro' ? (
<Button
variant="outlined"
size="large"
fullWidth
disabled
sx={{ mb: 1 }}
>
Coming Soon
</Button>
) : plan.tier === 'enterprise' ? (
<Button
variant="outlined"
size="large"
fullWidth
disabled
sx={{ mb: 1 }}
>
Contact Sales
</Button>
) : (
<>
<Button
variant={selectedPlan === plan.id ? "outlined" : "contained"}
color={getPlanColor(plan.tier)}
size="large"
fullWidth
disabled={subscribing}
onClick={() => setSelectedPlan(plan.id)}
sx={{ mb: 1 }}
>
{selectedPlan === plan.id ? 'Selected' : 'Select Plan'}
</Button>
{selectedPlan === plan.id && (
<Button
variant="contained"
color="success"
size="large"
fullWidth
disabled={subscribing}
onClick={() => handleSubscribe(plan.id)}
>
{subscribing ? <CircularProgress size={20} /> : `Subscribe to ${plan.name}`}
</Button>
)}
</>
)}
</CardActions>
</Card>
<PlanCard
plan={plan}
yearlyBilling={yearlyBilling}
selectedPlanId={selectedPlan}
subscribing={subscribing}
onSelectPlan={setSelectedPlan}
onSubscribe={handleSubscribe}
openKnowMoreModal={openKnowMoreModal}
/>
</Grid>
))}
</Grid>

View File

@@ -0,0 +1,954 @@
import React from 'react';
import {
Box,
Card,
CardActions,
CardContent,
Chip,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
IconButton,
Button,
CircularProgress,
Tooltip,
} from '@mui/material';
import {
Check as CheckIcon,
Star as StarIcon,
WorkspacePremium as PremiumIcon,
Info as InfoIcon,
Psychology,
Search as SearchIcon,
Edit,
Assistant,
Verified,
Timeline,
Analytics,
Support,
Business,
Group,
} from '@mui/icons-material';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import ImageIcon from '@mui/icons-material/Image';
import VideoIcon from '@mui/icons-material/VideoLibrary';
import AudioIcon from '@mui/icons-material/Audiotrack';
import { useTheme } from '@mui/material/styles';
interface SubscriptionPlan {
id: number;
name: string;
tier: string;
price_monthly: number;
price_yearly: number;
description: string;
features: string[];
limits: {
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
mistral_calls: number;
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
image_edit_calls?: number;
video_calls?: number;
audio_calls?: number;
ai_text_generation_calls_limit?: number;
};
}
interface PlanCardProps {
plan: SubscriptionPlan;
yearlyBilling: boolean;
selectedPlanId: number | null;
subscribing: boolean;
onSelectPlan: (planId: number) => void;
onSubscribe: (planId: number) => void;
openKnowMoreModal: (title: string, content: React.ReactNode) => void;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan,
yearlyBilling,
selectedPlanId,
subscribing,
onSelectPlan,
onSubscribe,
openKnowMoreModal,
}) => {
const theme = useTheme();
const getPlanIcon = (tier: string) => {
switch (tier) {
case 'free':
return <CheckIcon color="success" />;
case 'basic':
return <StarIcon color="primary" />;
case 'pro':
return <PremiumIcon color="secondary" />;
case 'enterprise':
return <PremiumIcon sx={{ color: theme.palette.warning.main }} />;
default:
return <CheckIcon />;
}
};
const getPlanColor = (tier: string) => {
switch (tier) {
case 'free':
return 'success' as const;
case 'basic':
return 'primary' as const;
case 'pro':
return 'secondary' as const;
case 'enterprise':
return 'warning' as const;
default:
return undefined;
}
};
const isSelected = selectedPlanId === plan.id;
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: isSelected ? `2px solid ${theme.palette.primary.main}` : '1px solid #e0e0e0',
transform: isSelected ? 'scale(1.02)' : 'scale(1)',
transition: 'all 0.3s ease-in-out',
}}
>
{plan.tier === 'pro' && (
<Chip
label="Most Popular"
color="primary"
size="small"
sx={{
position: 'absolute',
top: -8,
right: 16,
zIndex: 1,
}}
/>
)}
<CardContent sx={{ flexGrow: 1, textAlign: 'center' }}>
<Box sx={{ mb: 2 }}>{getPlanIcon(plan.tier)}</Box>
<Typography variant="h5" component="h2" gutterBottom>
{plan.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{plan.description}
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="h3" component="span">
${yearlyBilling ? plan.price_yearly : plan.price_monthly}
</Typography>
<Typography variant="body2" color="text.secondary">
/{yearlyBilling ? 'year' : 'month'}
</Typography>
{yearlyBilling && (
<Typography variant="caption" color="success.main" sx={{ display: 'block' }}>
Save ${(plan.price_monthly * 12 - plan.price_yearly).toFixed(0)} yearly
</Typography>
)}
</Box>
<List dense>
{(plan.tier === 'free' || plan.tier === 'basic') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
All ALwrity Tools Included:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Blog Writer"
secondary="AI-powered blog post creation with SEO optimization"
/>
<Tooltip
title="Learn more about Blog Writer"
onClick={() =>
openKnowMoreModal(
'Blog Writer',
<Box>
<Typography variant="h6" gutterBottom>
Blog Writer
</Typography>
<Typography variant="body2" paragraph>
Create engaging blog posts with AI assistance. Includes SEO optimization,
keyword research, and content structure suggestions.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> SEO-optimized content generation</Typography>
<Typography variant="body2"> Keyword research integration</Typography>
<Typography variant="body2"> Content structure suggestions</Typography>
<Typography variant="body2"> Publishing assistance</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="LinkedIn Writer"
secondary="Professional LinkedIn content creation and posting"
/>
<Tooltip
title="Learn more about LinkedIn Writer"
onClick={() =>
openKnowMoreModal(
'LinkedIn Writer',
<Box>
<Typography variant="h6" gutterBottom>
LinkedIn Writer
</Typography>
<Typography variant="body2" paragraph>
Create professional LinkedIn posts, articles, and carousels that engage your network and
showcase your expertise.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> Professional post generation</Typography>
<Typography variant="body2"> Article writing assistance</Typography>
<Typography variant="body2"> Carousel creation</Typography>
<Typography variant="body2"> Network engagement optimization</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Group color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Facebook Writer"
secondary="Engaging Facebook posts and content creation"
/>
<Tooltip
title="Learn more about Facebook Writer"
onClick={() =>
openKnowMoreModal(
'Facebook Writer',
<Box>
<Typography variant="h6" gutterBottom>
Facebook Writer
</Typography>
<Typography variant="body2" paragraph>
Create engaging Facebook posts, stories, and reels that drive engagement and grow your
community.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> Post and story creation</Typography>
<Typography variant="body2"> Reel script generation</Typography>
<Typography variant="body2"> Community management</Typography>
<Typography variant="body2"> Engagement optimization</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<MenuBookIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Story Studio"
secondary="Create campaign-ready stories with AI: outline, images, narration, and video"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Podcast Maker"
secondary="AI-powered research, scriptwriting, and voice narration"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Image Generator & Editor"
secondary="AI image creation and editing (background removal, inpainting)"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Studio & YouTube Creator"
secondary="AI video creation for social media and YouTube"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="All SEO Tools & Dashboards"
secondary="Keyword research, content optimization, SEO analytics"
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Timeline color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Content Planning & Strategy"
secondary="Content calendars, strategy planning, and analytics"
/>
</ListItem>
</>
)}
{(plan.tier === 'free' || plan.tier === 'pro' || plan.tier === 'enterprise') &&
(
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Platform Integrations:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Wix Integration"
secondary="Direct publishing to Wix websites"
/>
<Tooltip
title="Learn more about Wix integration"
onClick={() =>
openKnowMoreModal(
'Wix Integration',
<Box>
<Typography variant="h6" gutterBottom>
Wix Integration
</Typography>
<Typography variant="body2" paragraph>
Seamlessly publish your content directly to Wix websites.
No manual copying required.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> Direct blog post publishing</Typography>
<Typography variant="body2"> SEO metadata sync</Typography>
<Typography variant="body2"> Image optimization</Typography>
<Typography variant="body2"> Publishing queue management</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="WordPress Integration"
secondary="Publish to WordPress sites with API integration"
/>
<Tooltip
title="Learn more about WordPress integration"
onClick={() =>
openKnowMoreModal(
'WordPress Integration',
<Box>
<Typography variant="h6" gutterBottom>
WordPress Integration
</Typography>
<Typography variant="body2" paragraph>
Connect directly to WordPress sites for seamless content publishing.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2"> REST API integration</Typography>
<Typography variant="body2"> Draft and publish modes</Typography>
<Typography variant="body2"> Category and tag management</Typography>
<Typography variant="body2"> Featured image handling</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Analytics color="success" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Google Search Console"
secondary="SEO performance tracking and insights"
/>
<Tooltip
title="Learn more about GSC integration"
onClick={() =>
openKnowMoreModal(
'Google Search Console',
<Box>
<Typography variant="h6" gutterBottom>
Google Search Console
</Typography>
<Typography variant="body2" paragraph>
Monitor your website's SEO performance and get actionable insights
for content optimization.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• Search performance tracking</Typography>
<Typography variant="body2">• Keyword ranking insights</Typography>
<Typography variant="body2">• Technical SEO monitoring</Typography>
<Typography variant="body2">• Content optimization suggestions</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
</>
)}
{(plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Social Media & Website Management:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Group color="secondary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="6 Major Social Platforms"
secondary="LinkedIn, Facebook, Instagram, Twitter, TikTok, YouTube"
/>
<Tooltip
title="Learn more about social media platforms"
onClick={() =>
openKnowMoreModal(
'6 Major Social Platforms',
<Box>
<Typography variant="h6" gutterBottom>
6 Major Social Platforms
</Typography>
<Typography variant="body2" paragraph>
Comprehensive social media management across all major platforms with AI-powered content
optimization.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Features:</strong>
</Typography>
<Typography variant="body2">• Cross-platform scheduling</Typography>
<Typography variant="body2">• Content performance insights</Typography>
<Typography variant="body2">• Engagement analytics</Typography>
<Typography variant="body2">• Platform-specific optimization</Typography>
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Support color="secondary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Team Collaboration"
secondary="Invite team members, assign roles, and manage content workflows"
/>
</ListItem>
</>
)}
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Text Generation"
secondary={
plan.tier === 'free' || plan.tier === 'basic'
? 'AI-powered text content creation'
: 'Advanced text generation with multimodal capabilities'
}
/>
<Tooltip
title="Learn more about text generation"
onClick={() =>
openKnowMoreModal(
'Text Generation',
<Box>
<Typography variant="h6" gutterBottom>
AI Text Generation
</Typography>
<Typography variant="body2" paragraph>
Generate high-quality text content with AI assistance. From blog posts to social media updates,
create engaging content effortlessly.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Capabilities:</strong>
</Typography>
<Typography variant="body2">• Blog posts and articles</Typography>
<Typography variant="body2">• Social media content</Typography>
<Typography variant="body2">• Email newsletters</Typography>
<Typography variant="body2">• Marketing copy</Typography>
{(plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<Typography variant="body2">• Audio transcription</Typography>
<Typography variant="body2">• Video script writing</Typography>
</>
)}
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Assistant color="primary" fontSize="small" />
</ListItemIcon>
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<ListItemText
primary="Image Generation"
secondary={
plan.tier === 'free' || plan.tier === 'basic'
? 'AI image creation for visual content'
: 'Advanced image generation with video capabilities'
}
/>
<Tooltip
title="Learn more about image generation"
onClick={() =>
openKnowMoreModal(
'Image Generation',
<Box>
<Typography variant="h6" gutterBottom>
AI Image Generation
</Typography>
<Typography variant="body2" paragraph>
Create stunning visuals with AI-powered image generation. Perfect for social media, blog posts,
and marketing materials.
</Typography>
<Typography variant="body2" gutterBottom>
<strong>Capabilities:</strong>
</Typography>
<Typography variant="body2">• Social media graphics</Typography>
<Typography variant="body2">• Blog featured images</Typography>
<Typography variant="body2">• Marketing visuals</Typography>
<Typography variant="body2">• Custom illustrations</Typography>
{(plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<Typography variant="body2">• Video thumbnail generation</Typography>
<Typography variant="body2">• Animated graphics</Typography>
</>
)}
</Box>,
)
}
>
<IconButton size="small">
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
{(plan.tier === 'basic' || plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Audio Generation"
secondary={
plan.tier === 'basic'
? 'AI voice synthesis for podcasts, stories, and narration'
: 'AI-powered audio content creation and voice synthesis'
}
/>
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Generation"
secondary={
plan.tier === 'basic'
? 'Create AI videos for YouTube, social media, and stories'
: 'AI video creation with script writing and editing'
}
/>
</ListItem>
</>
)}
{plan.tier !== 'free' && (
<>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>
Support & Analytics:
</Typography>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Support color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Priority Support" />
</ListItem>
{plan.tier === 'pro' && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Analytics color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Advanced Analytics & Insights" />
</ListItem>
)}
{plan.tier === 'enterprise' && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Business color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Custom Integrations" />
</ListItem>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Support color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText primary="Dedicated Account Manager" />
</ListItem>
</>
)}
</>
)}
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 600 }}>
Monthly Usage Limits:
</Typography>
{plan.tier === 'basic' && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Psychology color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="50 AI Text Generations"
secondary="~16-25 blog posts or ~25-50 social posts per month"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem', fontWeight: 500 } }}
/>
</ListItem>
)}
{plan.tier !== 'basic' && (
<>
{plan.limits.gemini_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.gemini_calls === 0 ? '' : plan.limits.gemini_calls} Gemini AI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.openai_calls > 0 && (
<ListItem>
<ListItemText
primary={`${plan.limits.openai_calls === 0 ? '' : plan.limits.openai_calls} OpenAI calls`}
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
</>
)}
{plan.limits.stability_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<ImageIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.stability_calls} AI Images`}
secondary={
plan.tier === 'basic'
? 'Powered by open-source models (25% cost savings)'
: undefined
}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: plan.tier === 'basic' ? 500 : 400,
},
}}
/>
</ListItem>
)}
{(plan.limits.image_edit_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Edit color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.image_edit_calls ?? 0} Image Edits`}
secondary={
plan.tier === 'basic'
? 'Background removal, inpainting, recolor (50% cost savings with OSS)'
: undefined
}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: plan.tier === 'basic' ? 500 : 400,
},
}}
/>
</ListItem>
)}
{(plan.limits.video_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.video_calls ?? 0} AI Videos`}
secondary={
plan.tier === 'basic'
? '~5-6 full video projects (5 scenes each) per month'
: undefined
}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: plan.tier === 'basic' ? 500 : 400,
},
}}
/>
</ListItem>
)}
{(plan.limits.audio_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.audio_calls ?? 0} Audio Generations`}
secondary={
plan.tier === 'basic'
? 'Podcast narration, story audio, voice synthesis'
: undefined
}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: plan.tier === 'basic' ? 500 : 400,
},
}}
/>
</ListItem>
)}
{plan.limits.tavily_calls > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.tavily_calls} Research Searches`}
secondary="Web research, fact-checking, content discovery"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.monthly_cost > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<Verified color="success" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`$${plan.limits.monthly_cost} Monthly Cost Cap`}
secondary="Automatic protection - you'll never exceed this amount"
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
fontWeight: 500,
color: 'success.main',
},
}}
/>
</ListItem>
)}
{plan.tier === 'basic' && (
<Box
sx={{
mt: 1,
p: 1.5,
bgcolor: 'info.lighter',
borderRadius: 1,
mx: 2,
}}
>
<Typography
variant="caption"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, fontWeight: 500 }}
>
<StarIcon fontSize="small" sx={{ color: 'info.main' }} />
Powered by Open-Source AI Models
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}
>
We use cost-effective open-source models to give you more value. 25-50% savings vs
proprietary models.
</Typography>
</Box>
)}
</List>
</CardContent>
<CardActions sx={{ justifyContent: 'center', pb: 3, flexDirection: 'column', gap: 1 }}>
{plan.tier === 'pro' ? (
<Button variant="outlined" size="large" fullWidth disabled sx={{ mb: 1 }}>
Coming Soon
</Button>
) : plan.tier === 'enterprise' ? (
<Button variant="outlined" size="large" fullWidth disabled sx={{ mb: 1 }}>
Contact Sales
</Button>
) : (
<>
<Button
variant={isSelected ? 'outlined' : 'contained'}
color={getPlanColor(plan.tier)}
size="large"
fullWidth
disabled={subscribing}
onClick={() => onSelectPlan(plan.id)}
sx={{ mb: 1 }}
>
{isSelected ? 'Selected' : 'Select Plan'}
</Button>
{isSelected && (
<Button
variant="contained"
color="success"
size="large"
fullWidth
disabled={subscribing}
onClick={() => onSubscribe(plan.id)}
>
{subscribing ? <CircularProgress size={20} /> : `Subscribe to ${plan.name}`}
</Button>
)}
</>
)}
</CardActions>
</Card>
);
};
export default PlanCard;

View File

@@ -0,0 +1,212 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useSEOCopilotStore } from '../../stores/seoCopilotStore';
import SEOCopilotActions from './SEOCopilotActions';
const SEOCopilot: React.FC = () => {
const {
loadPersonalizationData,
error,
clearError,
isLoading
} = useSEOCopilotStore();
const { analysisData } = useSEOCopilotStore();
// Handle data loading and error states
useEffect(() => {
const initializeCopilot = async () => {
try {
await loadPersonalizationData();
} catch (error) {
console.error('Failed to initialize SEO Copilot:', error);
}
};
initializeCopilot();
}, [loadPersonalizationData]);
// Auto-clear errors after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
clearError();
}, 5000);
return () => clearTimeout(timer);
}
}, [error, clearError]);
// Get the CopilotKit API key from the same sources as App.tsx
// Check localStorage first, then fall back to environment variable
const publicApiKey = useMemo(() => {
const savedKey = typeof window !== 'undefined'
? localStorage.getItem('copilotkit_api_key')
: null;
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
const key = (savedKey || envKey).trim();
// Validate key format if present
if (key && !key.startsWith('ck_pub_')) {
console.warn('SEOCopilot: CopilotKit API key format invalid - must start with ck_pub_');
}
return key;
}, []);
// Derive a friendly site/brand name from the URL for personalization
const domainRootName = useMemo(() => {
const url = analysisData?.url;
if (!url) return '';
try {
const withProto = url.startsWith('http') ? url : `https://${url}`;
const host = new URL(withProto).hostname;
const parts = host.split('.').filter(Boolean);
const root = parts.length >= 2 ? parts[parts.length - 2] : parts[0] || '';
if (!root) return '';
return root.charAt(0).toUpperCase() + root.slice(1);
} catch {
return '';
}
}, [analysisData?.url]);
// Suggestions model: progressive disclosure
const topLevelGroups = useMemo(() => ([
{ title: 'Content analysis', message: 'Content analysis' },
{ title: 'Website/URL analysis', message: 'Web URL analysis' },
{ title: 'Technical SEO', message: 'Technical SEO' },
{ title: 'Strategy & planning', message: 'Strategy and planning' },
{ title: 'Monitoring & health', message: 'Monitoring and health' }
]), []);
const subSuggestionsByGroup = useMemo(() => ({
'Content analysis': [
{ title: 'Comprehensive content analysis', message: 'Analyze content comprehensively for my site' },
{ title: 'Optimize page content', message: 'Optimize page content for SEO' },
{ title: 'Generate meta descriptions', message: 'Generate meta descriptions for key pages' }
],
'Web URL analysis': [
{ title: 'Comprehensive SEO analysis', message: 'Run comprehensive SEO analysis for a URL' },
{ title: 'Analyze page speed', message: 'Analyze page speed for a URL' },
{ title: 'Analyze sitemap', message: 'Analyze sitemap for my site' },
{ title: 'Generate OpenGraph tags', message: 'Generate OpenGraph tags for a URL' }
],
'Technical SEO': [
{ title: 'Technical SEO audit', message: 'Run a technical SEO audit' },
{ title: 'Check SEO health', message: 'Check overall SEO health' },
{ title: 'Image alt text', message: 'Generate image alt text for pages' }
],
'Strategy and planning': [
{ title: 'Enterprise SEO analysis', message: 'Run enterprise SEO analysis' },
{ title: 'Content strategy', message: 'Analyze content strategy and recommendations' },
{ title: 'Customize SEO dashboard', message: 'Customize the SEO dashboard' }
],
'Monitoring and health': [
{ title: 'Website audit', message: 'Perform a website audit' },
{ title: 'Update SEO charts', message: 'Update SEO charts and visualizations' },
{ title: 'Explain an SEO concept', message: 'Explain an SEO concept in simple terms' }
]
}), []);
const [chatSuggestions, setChatSuggestions] = useState(topLevelGroups);
useEffect(() => {
loadPersonalizationData();
}, [loadPersonalizationData]);
return (
<>
{/* Loading indicator */}
{isLoading && (
<div className="seo-copilot-loading">
<div className="loading-spinner">
<div className="spinner"></div>
<p>Loading SEO Assistant...</p>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="seo-copilot-error">
<div className="error-message">
<span className="error-icon"></span>
<span className="error-text">{error}</span>
<button
className="error-dismiss"
onClick={clearError}
aria-label="Dismiss error"
>
×
</button>
</div>
</div>
)}
<SEOCopilotActions />
<style>{`
.seo-copilot-loading {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
z-index: 1300;
display: flex;
align-items: center;
gap: 8px;
}
.seo-copilot-error {
position: fixed;
top: 20px;
right: 20px;
background: #f44336;
color: white;
padding: 12px 16px;
border-radius: 8px;
z-index: 1300;
display: flex;
align-items: center;
gap: 8px;
max-width: 300px;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.error-dismiss {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin-left: 8px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #ffffff40;
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</>
);
};
export default SEOCopilot;

View File

@@ -18,12 +18,6 @@ const SEOCopilotKitProvider: React.FC<SEOCopilotKitProviderProps> = ({
children,
enableDebugMode = false
}) => {
const {
loadPersonalizationData,
error,
clearError,
isLoading
} = useSEOCopilotStore();
const { analysisData } = useSEOCopilotStore();
// Get the CopilotKit API key from the same sources as App.tsx
@@ -107,40 +101,11 @@ const SEOCopilotKitProvider: React.FC<SEOCopilotKitProviderProps> = ({
// Initialize the provider
useEffect(() => {
const initializeProvider = async () => {
try {
// Load personalization data on mount
await loadPersonalizationData();
if (enableDebugMode) {
console.log('🔧 SEO CopilotKit Provider initialized successfully');
console.log('🔑 CopilotKit API Key:', publicApiKey ? 'Configured' : 'Missing');
}
} catch (error) {
console.error('❌ Failed to initialize SEO CopilotKit Provider:', error);
}
};
initializeProvider();
}, [loadPersonalizationData, enableDebugMode, publicApiKey]);
// Error handling
useEffect(() => {
if (error && enableDebugMode) {
console.error('🚨 SEO CopilotKit Error:', error);
if (enableDebugMode) {
console.log('🔧 SEO CopilotKit Provider initialized successfully');
console.log('🔑 CopilotKit API Key:', publicApiKey ? 'Configured' : 'Missing');
}
}, [error, enableDebugMode]);
// Auto-clear errors after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
clearError();
}, 5000);
return () => clearTimeout(timer);
}
}, [error, clearError]);
}, [enableDebugMode, publicApiKey]);
return (
<CopilotKit publicApiKey={publicApiKey}>
@@ -194,33 +159,6 @@ Focus on actionable recommendations and use the registered tools.
{/* SEO CopilotKit Actions - Defines available actions */}
<SEOCopilotActions />
{/* Loading indicator */}
{isLoading && (
<div className="seo-copilotkit-loading">
<div className="loading-spinner">
<div className="spinner"></div>
<p>Loading SEO Assistant...</p>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="seo-copilotkit-error">
<div className="error-message">
<span className="error-icon"></span>
<span className="error-text">{error}</span>
<button
className="error-dismiss"
onClick={clearError}
aria-label="Dismiss error"
>
×
</button>
</div>
</div>
)}
{/* Main content */}
<div className="seo-copilotkit-content">
{children}

View File

@@ -17,10 +17,11 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress
CircularProgress,
Drawer
} from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth, useUser, SignInButton, SignOutButton, useClerk } from '@clerk/clerk-react';
import { useAuth, useUser, SignOutButton, useClerk } from '@clerk/clerk-react';
import { apiClient } from '../../api/client';
import {
Refresh as RefreshIcon,
@@ -32,13 +33,15 @@ import {
Schedule as ScheduleIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
Close as CloseIcon,
AutoAwesome as AIIcon
} from '@mui/icons-material';
// Shared components
import { DashboardContainer, GlassCard } from '../shared/styled';
import SEOAnalyzerPanel from './components/SEOAnalyzerPanel';
import { SEOCopilotKitProvider, SEOCopilotSuggestions } from './index';
import { SEOCopilotSuggestions } from './index';
import SEOCopilot from './SEOCopilot';
// Removed SEOCopilotTest
import useSEOCopilotStore from '../../stores/seoCopilotStore';
@@ -47,6 +50,8 @@ import { useSEODashboardStore } from '../../stores/seoDashboardStore';
// API
import { userDataAPI } from '../../api/userData';
import { SIFIndexingHealth } from '../../api/seoDashboard';
import { getSchedulerDashboard, SchedulerJob } from '../../api/schedulerDashboard';
// Shared components
import PlatformAnalytics from '../shared/PlatformAnalytics';
@@ -81,9 +86,7 @@ const SEODashboard: React.FC = () => {
analysisError,
setData,
setLoading,
setError,
runSEOAnalysis,
checkAndRunInitialAnalysis,
refreshSEOAnalysis,
getAnalysisFreshness,
} = useSEODashboardStore();
@@ -109,7 +112,6 @@ const SEODashboard: React.FC = () => {
// Menu state
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
// Competitor analysis data from onboarding step 3
const [competitorAnalysisData, setCompetitorAnalysisData] = useState<any>(null);
@@ -119,6 +121,11 @@ const SEODashboard: React.FC = () => {
const [competitiveSitemapBenchmarkingReport, setCompetitiveSitemapBenchmarkingReport] = useState<any>(null);
const [competitiveSitemapBenchmarkingLoading, setCompetitiveSitemapBenchmarkingLoading] = useState(false);
const [competitiveSitemapBenchmarkingError, setCompetitiveSitemapBenchmarkingError] = useState<string | null>(null);
const [sifHealth, setSifHealth] = useState<SIFIndexingHealth | null>(null);
const [sifDetailsOpen, setSifDetailsOpen] = useState(false);
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJob[] | null>(null);
const [schedulerJobsLoading, setSchedulerJobsLoading] = useState(false);
const [schedulerJobsError, setSchedulerJobsError] = useState<string | null>(null);
// PlatformAnalytics refresh handle
const platformRefreshRef = useRef<(() => Promise<void>) | null>(null);
@@ -140,6 +147,27 @@ const SEODashboard: React.FC = () => {
fetchStrategicInsightsHistory();
}, []);
useEffect(() => {
if (!sifDetailsOpen || schedulerJobs || schedulerJobsLoading) return;
setSchedulerJobsLoading(true);
(async () => {
try {
const dashboard = await getSchedulerDashboard();
const currentUserId = dashboard.user_isolation?.current_user_id || null;
const filtered = dashboard.jobs.filter((job) =>
currentUserId ? job.user_id === currentUserId : Boolean(job.user_id)
);
setSchedulerJobs(filtered);
setSchedulerJobsError(null);
} catch (e) {
console.error('Failed to load scheduler jobs for SEO dashboard:', e);
setSchedulerJobsError('Failed to load scheduler jobs');
} finally {
setSchedulerJobsLoading(false);
}
})();
}, [sifDetailsOpen, schedulerJobs, schedulerJobsLoading]);
const fetchStrategicInsightsHistory = async () => {
setStrategicInsightsLoading(true);
try {
@@ -232,14 +260,16 @@ const SEODashboard: React.FC = () => {
setLoading(true);
// Fetch platform status and user data in parallel
const [platformResponse, userData] = await Promise.all([
const [platformResponse, userData, sifHealthResponse] = await Promise.all([
apiClient.get('/api/seo-dashboard/platforms'),
userDataAPI.getUserData()
userDataAPI.getUserData(),
apiClient.get('/api/seo-dashboard/sif-health')
]);
console.log('Platform status response:', platformResponse.status, platformResponse.statusText);
console.log('Platform status data:', platformResponse.data);
setPlatformStatus(platformResponse.data);
setSifHealth(sifHealthResponse.data);
websiteUrl = userData?.website_url || 'https://alwrity.com';
@@ -514,16 +544,15 @@ const SEODashboard: React.FC = () => {
}
return (
<SEOCopilotKitProvider enableDebugMode={false}>
<DashboardContainer>
<Container maxWidth="xl">
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{/* Professional Compact Header */}
<DashboardContainer>
<Container maxWidth="xl">
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{/* Professional Compact Header */}
<Box sx={{
mb: 4,
display: 'flex',
@@ -593,6 +622,69 @@ const SEODashboard: React.FC = () => {
}}
/>
</Tooltip>
{sifHealth && (
<Tooltip title="Semantic Indexing Status (SIF)">
<Box
onClick={() => setSifDetailsOpen(true)}
sx={{
ml: 2,
px: 2,
py: 0.75,
borderRadius: 999,
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
bgcolor:
sifHealth.status === 'healthy'
? 'rgba(76, 175, 80, 0.15)'
: sifHealth.status === 'warning'
? 'rgba(255, 152, 0, 0.15)'
: sifHealth.status === 'critical'
? 'rgba(244, 67, 54, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
sifHealth.status === 'healthy'
? '1px solid rgba(76, 175, 80, 0.5)'
: sifHealth.status === 'warning'
? '1px solid rgba(255, 152, 0, 0.5)'
: sifHealth.status === 'critical'
? '1px solid rgba(244, 67, 54, 0.5)'
: '1px solid rgba(255, 255, 255, 0.15)',
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor:
sifHealth.status === 'healthy'
? '#4CAF50'
: sifHealth.status === 'warning'
? '#FF9800'
: sifHealth.status === 'critical'
? '#F44336'
: 'rgba(255, 255, 255, 0.5)',
}}
/>
<Typography
variant="body2"
sx={{ color: 'rgba(255, 255, 255, 0.9)', fontWeight: 500 }}
>
Semantic Indexing:{' '}
{sifHealth.status === 'not_scheduled'
? 'Not scheduled yet'
: sifHealth.status === 'healthy'
? 'Up to date'
: sifHealth.status === 'warning'
? 'Issues detected'
: 'Needs intervention'}
</Typography>
</Box>
</Tooltip>
)}
</Box>
{/* Right Section - User Menu */}
@@ -1444,11 +1536,244 @@ const SEODashboard: React.FC = () => {
<Box sx={{ mt: 4 }}>
<SEOCopilotSuggestions />
</Box>
{/* SEO Copilot Component for data loading and error handling */}
<SEOCopilot />
</motion.div>
</AnimatePresence>
</Container>
<Drawer
anchor="right"
open={sifDetailsOpen}
onClose={() => setSifDetailsOpen(false)}
PaperProps={{
sx: {
width: { xs: '100%', sm: 360 },
maxWidth: '100vw',
bgcolor: 'rgba(15, 23, 42, 0.98)',
color: 'white'
}
}}
>
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: 'rgba(148, 163, 184, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<Typography variant="h6">Semantic Indexing Details</Typography>
<IconButton onClick={() => setSifDetailsOpen(false)} sx={{ color: 'rgba(148,163,184,0.9)' }}>
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ p: 2 }}>
{sifHealth ? (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Overall Status
</Typography>
<Typography variant="body1">
{sifHealth.status === 'not_scheduled'
? 'Not scheduled'
: sifHealth.status === 'healthy'
? 'Healthy'
: sifHealth.status === 'warning'
? 'Warning'
: 'Critical'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Next Scheduled Run
</Typography>
<Typography variant="body1">
{sifHealth.task?.next_execution
? new Date(sifHealth.task.next_execution).toLocaleString()
: 'Not scheduled'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Last Success
</Typography>
<Typography variant="body1">
{sifHealth.task?.last_success
? new Date(sifHealth.task.last_success).toLocaleString()
: 'No successful runs yet'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Last Failure
</Typography>
<Typography variant="body1">
{sifHealth.task?.last_failure
? new Date(sifHealth.task.last_failure).toLocaleString()
: 'No failures recorded'}
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Consecutive Failures
</Typography>
<Typography variant="body1">
{sifHealth.task?.consecutive_failures ?? 0}
</Typography>
</Box>
{sifHealth.last_run?.status && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)', mb: 0.5 }}>
Last Run Status
</Typography>
<Typography variant="body1">
{sifHealth.last_run.status}
{sifHealth.last_run.time
? `${new Date(sifHealth.last_run.time).toLocaleString()}`
: ''}
</Typography>
</Box>
)}
{sifHealth.last_run?.error_message && (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(248,113,113,0.9)', mb: 0.5 }}>
Last Error (snippet)
</Typography>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 12,
bgcolor: 'rgba(15,23,42,0.9)',
borderRadius: 1,
p: 1.5,
border: '1px solid rgba(148,163,184,0.3)'
}}
>
{sifHealth.last_run.error_message}
</Typography>
</Box>
)}
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ color: 'white', mb: 1 }}>
Scheduled Jobs For Your Account
</Typography>
{schedulerJobsLoading && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} sx={{ color: 'rgba(148,163,184,0.9)' }} />
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)' }}>
Loading scheduler jobs
</Typography>
</Box>
)}
{schedulerJobsError && !schedulerJobsLoading && (
<Typography variant="body2" sx={{ color: 'rgba(248,113,113,0.9)' }}>
{schedulerJobsError}
</Typography>
)}
{!schedulerJobsLoading && !schedulerJobsError && schedulerJobs && schedulerJobs.length === 0 && (
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)' }}>
No scheduled jobs found for this account.
</Typography>
)}
{!schedulerJobsLoading && !schedulerJobsError && schedulerJobs && schedulerJobs.length > 0 && (
<Box
sx={{
mt: 1,
maxHeight: 220,
overflowY: 'auto',
borderRadius: 1,
border: '1px solid rgba(51,65,85,0.9)',
bgcolor: 'rgba(15,23,42,0.9)'
}}
>
{schedulerJobs
.slice()
.sort((a, b) => {
const aTime = a.next_run_time ? new Date(a.next_run_time).getTime() : Number.MAX_SAFE_INTEGER;
const bTime = b.next_run_time ? new Date(b.next_run_time).getTime() : Number.MAX_SAFE_INTEGER;
return aTime - bTime;
})
.map((job) => {
const label =
job.task_category === 'website_analysis'
? 'Website Analysis'
: job.task_category === 'platform_insights'
? 'Platform Insights'
: job.task_category === 'deep_website_crawl'
? 'Deep Website Crawl'
: job.function_name || job.id;
const subtitle =
job.website_url ||
(job.platform ? `${job.platform.toUpperCase()} insights` : job.job_store);
const nextRun = job.next_run_time
? new Date(job.next_run_time).toLocaleString()
: 'Not scheduled';
return (
<Box
key={job.id}
sx={{
px: 1.5,
py: 1,
borderBottom: '1px solid rgba(30,41,59,0.9)',
'&:last-of-type': { borderBottom: 'none' }
}}
>
<Typography
variant="body2"
sx={{ color: 'rgba(248,250,252,0.95)', fontWeight: 500 }}
>
{label}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', color: 'rgba(148,163,184,0.9)' }}
>
{subtitle}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', color: 'rgba(148,163,184,0.7)', mt: 0.25 }}
>
Next run: {nextRun}
{job.frequency ? `${job.frequency}` : ''}
</Typography>
</Box>
);
})}
</Box>
)}
</Box>
</>
) : (
<Typography variant="body2" sx={{ color: 'rgba(148,163,184,0.9)' }}>
No semantic indexing information available.
</Typography>
)}
</Box>
</Drawer>
</DashboardContainer>
</SEOCopilotKitProvider>
);
};

View File

@@ -7,6 +7,7 @@ export { default as SEOCopilotContext } from './SEOCopilotContext';
export { default as SEOCopilotActions } from './SEOCopilotActions';
export { default as SEOCopilotSuggestions } from './SEOCopilotSuggestions';
export { default as SEOCopilotTest } from './SEOCopilotTest';
export { default as SEOCopilot } from './SEOCopilot';
// Store and Services
export { useSEOCopilotStore, useSEOCopilotAnalysis, useSEOCopilotSuggestions, useSEOCopilotDashboard } from '../../stores/seoCopilotStore';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import {
Box,
Paper,
@@ -13,18 +13,22 @@ import {
} from '@mui/material';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import SaveIcon from '@mui/icons-material/Save';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { triggerSubscriptionError } from '../../../api/client';
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay';
import SceneVideoApproval from '../components/SceneVideoApproval';
import { PrimaryButton } from '../../PodcastMaker/ui/PrimaryButton';
interface StoryExportProps {
state: ReturnType<typeof useStoryWriterState>;
onSaveProject?: () => void;
isSavingProject?: boolean;
}
const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
const StoryExport: React.FC<StoryExportProps> = ({ state, onSaveProject, isSavingProject }) => {
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const [videoMessage, setVideoMessage] = useState<string>('');
@@ -772,8 +776,23 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
<Divider sx={{ my: 3 }} />
{/* Export Actions */}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
{onSaveProject && (
<PrimaryButton
onClick={onSaveProject}
startIcon={<SaveIcon />}
loading={Boolean(isSavingProject)}
ariaLabel="Save story project"
tooltip={
state.projectId
? 'Save changes to My Projects'
: 'Save this story to My Projects'
}
sx={{ minWidth: 180 }}
>
{state.projectId ? 'Save to My Projects' : 'Save Story to My Projects'}
</PrimaryButton>
)}
<Button variant="outlined" onClick={handleCopyToClipboard}>
Copy to Clipboard
</Button>

View File

@@ -2,25 +2,17 @@ import React, { useEffect, useRef, useState } from 'react';
import {
Box,
Typography,
Button,
TextField,
Alert,
CircularProgress,
Snackbar,
FormControlLabel,
Checkbox,
Dialog,
} from '@mui/material';
import GlobalStyles from '@mui/material/GlobalStyles';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { useStoryWriterState, SceneAnimationResume } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { aiApiClient, triggerSubscriptionError } from '../../../api/client';
import OutlineHoverActions from './StoryOutlineParts/OutlineHoverActions';
import EditSectionModal from './StoryOutlineParts/EditSectionModal';
import { leftPageVariants, rightPageVariants } from './StoryOutlineParts/pageVariants';
import { outlineActionButtonSx, primaryButtonSx } from './StoryOutlineParts/buttonStyles';
import BookPages from './StoryOutlineParts/BookPages';
import OutlineActionsBar from './StoryOutlineParts/OutlineActionsBar';
import ImageEditModal from './StoryOutlineParts/ImageEditModal';
@@ -28,8 +20,10 @@ import AudioScriptModal from './StoryOutlineParts/AudioScriptModal';
import CharactersModal from './StoryOutlineParts/CharactersModal';
import KeyEventsModal from './StoryOutlineParts/KeyEventsModal';
import TitleEditModal from './StoryOutlineParts/TitleEditModal';
const MotionBox = motion.create(Box);
import {
StoryImageGenerationModal,
StoryImageGenerationSettings,
} from '../components/StoryImageGenerationModal';
// styles imported
@@ -53,6 +47,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [hasVideoLoadError, setVideoLoadError] = useState<Set<number>>(new Set());
const [outlineToastOpen, setOutlineToastOpen] = useState(false);
const lastToastSceneCount = useRef<number | null>(null);
const lastSavedSceneCount = useRef<number | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editText, setEditText] = useState<string>('');
const [aiFeedback, setAiFeedback] = useState<string>('');
@@ -62,6 +57,8 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [isRegeneratingSceneAudio, setIsRegeneratingSceneAudio] = useState<boolean>(false);
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const [imagePromptDraft, setImagePromptDraft] = useState('');
const [isImageSettingsModalOpen, setIsImageSettingsModalOpen] = useState(false);
const [isImageSettingsGenerating, setIsImageSettingsGenerating] = useState(false);
const [isAudioModalOpen, setIsAudioModalOpen] = useState(false);
const [audioScriptDraft, setAudioScriptDraft] = useState('');
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
@@ -69,11 +66,13 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
const [animatingSceneNumber, setAnimatingSceneNumber] = useState<number | null>(null);
const [isRefiningAnimeScene, setIsRefiningAnimeScene] = useState(false);
const [isImageFullscreenOpen, setIsImageFullscreenOpen] = useState(false);
// Use state from hook instead of local state
const sceneImages = state.sceneImages || new Map<number, string>();
const sceneAudio = state.sceneAudio || new Map<number, string>();
const sceneAnimatedVideos = state.sceneAnimatedVideos || new Map<number, string>();
const sceneAnimatedVideos = React.useMemo(() => state.sceneAnimatedVideos || new Map<number, string>(), [state.sceneAnimatedVideos]);
const sceneAnimationResumables = state.sceneAnimationResumables || new Map<number, SceneAnimationResume>();
const updateSceneAnimatedVideo = (sceneNumber: number, videoUrl: string) => {
@@ -236,6 +235,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const sceneCount = scenes.length;
const hasScenes = state.isOutlineStructured && scenes.length > 0;
const hasOutlineScenes = Boolean(state.outlineScenes && state.outlineScenes.length > 0);
const hasAnimeBible = Boolean(state.animeBible);
const resumableScenesArray = Array.from(sceneAnimationResumables.entries());
const resumableSummaryMessage =
resumableScenesArray.length === 0
@@ -254,6 +254,20 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
}, [state.isOutlineStructured, sceneCount]);
useEffect(() => {
if (!state.projectId) {
return;
}
if (!state.isOutlineStructured || sceneCount <= 0) {
return;
}
if (lastSavedSceneCount.current === sceneCount) {
return;
}
lastSavedSceneCount.current = sceneCount;
state.saveProjectToDb();
}, [state.projectId, state.isOutlineStructured, sceneCount, state.saveProjectToDb, state]);
useEffect(() => {
if (hasScenes) {
setCurrentSceneIndex(0);
@@ -284,10 +298,12 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const loadImage = async () => {
try {
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
const cleanUrl = currentSceneImageUrl.split('?')[0];
// Use relative URL path directly (aiApiClient will add base URL and auth)
const imageUrl = currentSceneImageUrl.startsWith('/')
? currentSceneImageUrl
: `/${currentSceneImageUrl}`;
const imageUrl = cleanUrl.startsWith('/')
? cleanUrl
: `/${cleanUrl}`;
// Use aiApiClient to get authenticated response with blob
const response = await aiApiClient.get(imageUrl, {
responseType: 'blob',
@@ -312,7 +328,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
};
loadImage();
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError, imageBlobUrls]);
// Fetch video as blob with authentication
useEffect(() => {
@@ -353,7 +369,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
};
loadVideo();
}, [currentSceneNumber, sceneAnimatedVideos, hasVideoLoadError, videoBlobUrls]);
}, [currentSceneNumber, sceneAnimatedVideos, hasVideoLoadError, videoBlobUrls, audioBlobUrls, imageBlobUrls]);
// Cleanup blob URLs when component unmounts or scenes change
useEffect(() => {
@@ -392,6 +408,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
premise: state.premise,
outline: state.outline,
story_content: state.storyContent,
anime_bible: state.animeBible,
});
// Reset image/audio/video load errors when scene changes (to allow retry for new scene)
@@ -485,7 +502,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
};
loadAudio();
}, [currentSceneAudioUrl, currentSceneNumber, currentSceneAudioFullUrl, hasAudioLoadError, sceneAudio]);
}, [currentSceneAudioUrl, currentSceneNumber, currentSceneAudioFullUrl, hasAudioLoadError, sceneAudio, state.enableNarration]);
const handlePrevScene = () => {
if (canGoPrev) {
@@ -513,6 +530,11 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
setIsImageModalOpen(true);
};
const handleOpenAdvancedImageSettings = (prompt: string) => {
setImagePromptDraft(prompt);
setIsImageSettingsModalOpen(true);
};
const openAudioModal = () => {
setAudioScriptDraft(currentScene?.audio_narration || '');
setIsAudioModalOpen(true);
@@ -560,6 +582,53 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
};
const handleGenerateImageWithSettings = async (
settings: StoryImageGenerationSettings,
) => {
if (!hasScenes || !currentScene) {
return;
}
setIsImageSettingsGenerating(true);
try {
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
const resp = await storyWriterApi.regenerateSceneImage({
scene_number: sceneNum,
scene_title: sceneTitle,
prompt: settings.prompt.trim(),
provider: state.imageProvider || undefined,
width: state.imageWidth,
height: state.imageHeight,
model: settings.model || state.imageModel || undefined,
});
if (resp.success && resp.image_url) {
const nextMap = new Map(state.sceneImages || []);
nextMap.set(sceneNum, resp.image_url);
state.setSceneImages(nextMap);
const updated = [...scenes];
updated[currentSceneIndex] = {
...updated[currentSceneIndex],
image_prompt: settings.prompt.trim(),
};
(state.setOutlineScenes as any)(updated);
setImagePromptDraft(settings.prompt.trim());
setIsImageSettingsModalOpen(false);
setIsImageModalOpen(false);
} else {
throw new Error(resp.error || 'Failed to regenerate image');
}
} catch (err: any) {
console.error('Failed to regenerate scene image with settings:', err);
setError(err?.message || 'Failed to regenerate scene image');
} finally {
setIsImageSettingsGenerating(false);
}
};
const applySuggestion = (index: number) => {
const chosen = aiSuggestions[index];
if (chosen) {
@@ -586,6 +655,10 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
try {
const request = state.getRequest();
const response = await storyWriterApi.generateOutline(state.premise, request);
if (response.anime_bible) {
state.setAnimeBible(response.anime_bible);
}
if (response.success && response.outline) {
// Handle structured outline (scenes) or plain text outline
@@ -618,8 +691,14 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
};
const handleContinue = () => {
const handleContinue = async () => {
if (!state.premise || (!state.outline && !state.outlineScenes)) {
setError('Please generate a premise and outline first');
return;
}
if (state.outline || state.outlineScenes) {
state.setAutoGenerateOnWriting(true);
onNext();
}
};
@@ -712,6 +791,62 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
};
const handleRefineCurrentSceneAnime = async () => {
if (!hasScenes || !currentScene) {
setError('Please generate your outline before refining scenes.');
return;
}
if (!state.animeBible) {
setError('Anime story bible is not available. Generate an anime outline first.');
return;
}
setIsRefiningAnimeScene(true);
setError(null);
try {
const storyRequest = state.getRequest();
const response = await storyWriterApi.refineAnimeSceneText({
scene: currentScene,
persona: storyRequest.persona,
story_setting: storyRequest.story_setting,
character_input: storyRequest.character_input,
plot_elements: storyRequest.plot_elements,
writing_style: storyRequest.writing_style,
story_tone: storyRequest.story_tone,
narrative_pov: storyRequest.narrative_pov,
audience_age_group: storyRequest.audience_age_group,
content_rating: storyRequest.content_rating,
anime_bible: state.animeBible || null,
});
if (response.success && response.scene) {
const refinedScene = response.scene;
const nextScenes = [...scenes];
if (currentSceneIndex >= 0 && currentSceneIndex < nextScenes.length) {
nextScenes[currentSceneIndex] = refinedScene;
}
state.setOutlineScenes(nextScenes);
const formattedOutline = nextScenes
.map((scene, idx2) =>
`Scene ${scene.scene_number || idx2 + 1}: ${scene.title}\n${scene.description}`
)
.join('\n\n');
state.setOutline(formattedOutline);
} else {
throw new Error('Failed to refine scene with anime bible');
}
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to refine scene with anime bible';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsRefiningAnimeScene(false);
}
};
const handleAnimateScene = async () => {
if (!hasScenes || !currentScene) {
setError('Please generate your outline before animating scenes.');
@@ -810,57 +945,6 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
}
};
const handleRegenerateCurrentSceneImage = async () => {
if (!hasScenes || !currentScene) return;
setIsRegeneratingSceneImage(true);
try {
const resp = await storyWriterApi.generateSceneImages({
scenes: [currentScene],
provider: state.imageProvider || undefined,
width: state.imageWidth,
height: state.imageHeight,
model: state.imageModel || undefined,
});
if (resp.success && resp.images && resp.images.length > 0) {
const img = resp.images[0];
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const nextMap = new Map(state.sceneImages || []);
nextMap.set(sceneNum, img.image_url);
state.setSceneImages(nextMap);
}
} catch (e) {
console.warn('Failed to regenerate image for current scene', e);
} finally {
setIsRegeneratingSceneImage(false);
}
};
const handleRegenerateCurrentSceneAudio = async () => {
if (!hasScenes || !currentScene) return;
if (!state.enableNarration) return;
setIsRegeneratingSceneAudio(true);
try {
const resp = await storyWriterApi.generateSceneAudio({
scenes: [currentScene],
provider: state.audioProvider,
lang: state.audioLang,
slow: state.audioSlow,
rate: state.audioRate,
});
if (resp.success && resp.audio_files && resp.audio_files.length > 0) {
const au = resp.audio_files[0];
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const nextMap = new Map(state.sceneAudio || []);
nextMap.set(sceneNum, au.audio_url);
state.setSceneAudio(nextMap);
}
} catch (e) {
console.warn('Failed to regenerate audio for current scene', e);
} finally {
setIsRegeneratingSceneAudio(false);
}
};
return (
<Box sx={{ mt: 2 }}>
<GlobalStyles
@@ -927,6 +1011,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
audioUrl={resolvedSceneAudioUrl || null}
hasAudio={hasAudioForScene}
onOpenImageModal={openImageModal}
onOpenImageFullscreen={() => setIsImageFullscreenOpen(true)}
onOpenAudioModal={openAudioModal}
onOpenCharactersModal={openCharactersModal}
onOpenKeyEventsModal={openKeyEventsModal}
@@ -942,6 +1027,9 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
resumeInfo={currentSceneResumeInfo}
isAnimatingScene={isCurrentSceneAnimating}
animatedVideoUrl={currentSceneAnimatedVideoUrl}
onRefineAnimeScene={handleRefineCurrentSceneAnime}
isRefiningAnimeScene={isRefiningAnimeScene}
hasAnimeBible={hasAnimeBible}
/>
<OutlineActionsBar
isGenerating={isGenerating}
@@ -969,6 +1057,42 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
sx={{ mb: 3 }}
/>
)}
<Dialog
open={isImageFullscreenOpen}
onClose={() => setIsImageFullscreenOpen(false)}
maxWidth="lg"
fullWidth
>
<Box
sx={{
bgcolor: 'black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 2,
}}
>
{currentSceneImageFullUrl ? (
<Box
component="img"
src={currentSceneImageFullUrl}
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
sx={{
width: '100%',
height: 'auto',
maxHeight: '85vh',
objectFit: 'contain',
display: 'block',
}}
/>
) : (
<Typography variant="body2" sx={{ color: 'white' }}>
No image is available for this scene yet.
</Typography>
)}
</Box>
</Dialog>
<EditSectionModal
open={isEditModalOpen}
sceneNumber={currentSceneNumber}
@@ -1002,7 +1126,7 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
try {
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
const resp = await storyWriterApi.regenerateSceneImage({
scene_number: sceneNum,
scene_title: sceneTitle,
@@ -1012,26 +1136,23 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
height: state.imageHeight,
model: state.imageModel || undefined,
});
if (resp.success && resp.image_url) {
const nextMap = new Map(state.sceneImages || []);
nextMap.set(sceneNum, resp.image_url);
state.setSceneImages(nextMap);
// Update the scene with the new prompt if generation was successful
const updated = [...scenes];
updated[currentSceneIndex] = { ...updated[currentSceneIndex], image_prompt: prompt.trim() };
(state.setOutlineScenes as any)(updated);
setImagePromptDraft(prompt.trim());
// Close the modal after successful regeneration
setIsImageModalOpen(false);
} else {
throw new Error(resp.error || 'Failed to regenerate image');
}
} catch (err: any) {
console.error('Failed to regenerate scene image:', err);
throw err; // Re-throw to be handled by modal
throw err;
} finally {
setIsRegeneratingSceneImage(false);
}
@@ -1040,6 +1161,16 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
imageWidth={state.imageWidth}
imageHeight={state.imageHeight}
imageModel={state.imageModel}
onOpenAdvancedSettings={handleOpenAdvancedImageSettings}
/>
<StoryImageGenerationModal
open={isImageSettingsModalOpen}
onClose={() => setIsImageSettingsModalOpen(false)}
onGenerate={handleGenerateImageWithSettings}
initialPrompt={imagePromptDraft}
sceneTitle={currentScene?.title || undefined}
storyMode={state.storyMode}
isGenerating={isImageSettingsGenerating}
/>
<AudioScriptModal
open={isAudioModalOpen}

View File

@@ -7,6 +7,7 @@ import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import ReplayIcon from '@mui/icons-material/Replay';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import { OperationButton } from '../../../shared/OperationButton';
import { leftPageVariants, rightPageVariants } from './pageVariants';
import { StoryScene } from '../../../../services/storyWriterApi';
@@ -31,6 +32,7 @@ interface BookPagesProps {
audioUrl: string | null;
hasAudio: boolean;
onOpenImageModal: () => void;
onOpenImageFullscreen?: () => void;
onOpenAudioModal: () => void;
onOpenCharactersModal: () => void;
onOpenKeyEventsModal: () => void;
@@ -42,6 +44,9 @@ interface BookPagesProps {
isAnimatingScene?: boolean;
animatedVideoUrl?: string | null;
resumeInfo?: SceneAnimationResume | null;
onRefineAnimeScene?: () => void;
isRefiningAnimeScene?: boolean;
hasAnimeBible?: boolean;
}
const BookPages: React.FC<BookPagesProps> = ({
@@ -57,6 +62,7 @@ const BookPages: React.FC<BookPagesProps> = ({
onImageError,
narrationEnabled,
onOpenImageModal,
onOpenImageFullscreen,
onOpenAudioModal,
audioUrl,
hasAudio,
@@ -70,6 +76,9 @@ const BookPages: React.FC<BookPagesProps> = ({
isAnimatingScene,
animatedVideoUrl,
resumeInfo,
onRefineAnimeScene,
isRefiningAnimeScene,
hasAnimeBible,
}) => {
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
const showAnimatedVideo = Boolean(animatedVideoUrl);
@@ -157,6 +166,7 @@ const BookPages: React.FC<BookPagesProps> = ({
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
display: 'flex',
flexDirection: 'column',
position: 'relative',
'&:hover': canGoPrev
? {
transform: 'translateX(-4px) rotate(-0.3deg)',
@@ -177,15 +187,236 @@ const BookPages: React.FC<BookPagesProps> = ({
},
}}
>
{hasImage && (
<Box
sx={{
position: 'absolute',
top: 20,
right: 20,
display: 'flex',
alignItems: 'center',
gap: 1,
zIndex: 4,
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<Tooltip title="View image full size">
<Box
role="button"
aria-label="View image full size"
onClick={(e) => {
e.stopPropagation();
if (onOpenImageFullscreen) {
onOpenImageFullscreen();
}
}}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #111827 0%, #4b5563 100%)',
boxShadow: '0 4px 10px rgba(15,23,42,0.35)',
color: 'white',
cursor: 'pointer',
}}
>
<OpenInFullIcon fontSize="small" />
</Box>
</Tooltip>
<Tooltip title="Edit scene image prompt">
<Box
role="button"
aria-label="Edit scene image"
onClick={(e) => {
e.stopPropagation();
onOpenImageModal();
}}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 4px 10px rgba(127,90,240,0.3)',
color: 'white',
cursor: 'pointer',
}}
>
<EditNoteIcon fontSize="small" />
</Box>
</Tooltip>
{hasImage && onAnimateScene && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-5s',
operation_type: 'scene_animation',
actual_provider_name: 'wavespeed',
}}
label="Animate Scene"
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '6px',
width: 34,
height: 34,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1f8a70 0%, #32d9c8 100%)',
boxShadow: '0 4px 10px rgba(31,138,112,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #1a7a60 0%, #2dc9b8 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="bottom"
/>
</Box>
)}
{hasImage && hasAudio && onAnimateWithVoiceover && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'wavespeed-ai/infinitetalk',
operation_type: 'scene_animation_voiceover',
actual_provider_name: 'wavespeed',
}}
label="Animate with Voiceover"
variant="contained"
size="small"
startIcon={<GraphicEqIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateWithVoiceover}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '6px',
width: 34,
height: 34,
borderRadius: '50%',
background: 'linear-gradient(135deg, #733dd9 0%, #bb86fc 100%)',
boxShadow: '0 4px 10px rgba(115,61,217,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #6030ba 0%, #a974f1 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="bottom"
/>
</Box>
)}
{resumeInfo && onResumeScene && (
<Tooltip
title={resumeInfo.message || 'Resume animation download (no extra cost)'}
placement="bottom"
>
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-resume',
operation_type: 'scene_animation_resume',
actual_provider_name: 'wavespeed',
}}
label="Resume Animation"
variant="contained"
size="small"
startIcon={<ReplayIcon />}
showCost={false}
checkOnHover={false}
checkOnMount={false}
onClick={onResumeScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '6px',
width: 34,
height: 34,
borderRadius: '50%',
background: 'linear-gradient(135deg, #b35c1e 0%, #f5a623 100%)',
boxShadow: '0 4px 10px rgba(179,92,30,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #9c511a 0%, #e1911c 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="bottom"
/>
</Box>
</Tooltip>
)}
</Box>
)}
<Box sx={{ flex: '0 0 auto' }}>
<Typography variant="overline" sx={{ color: '#7a5335', letterSpacing: 4, fontWeight: 600, display: 'block' }}>
Scene {currentSceneNumber} of {scenesLength}
</Typography>
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 1, '&:hover .title-edit': { opacity: 1, pointerEvents: 'auto' } }}>
<Box
sx={{
mt: 1,
display: 'inline-flex',
alignItems: 'center',
gap: 1,
'&:hover .title-edit': { opacity: 1, pointerEvents: 'auto' },
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<Typography
variant="h4"
sx={{
mt: 1,
color: '#2C2416',
fontFamily: `'Playfair Display', serif`,
fontWeight: 600,
@@ -199,17 +430,20 @@ const BookPages: React.FC<BookPagesProps> = ({
className="title-edit"
role="button"
aria-label="Edit title"
onClick={(e) => { e.stopPropagation(); onOpenTitleModal(); }}
onClick={(e) => {
e.stopPropagation();
onOpenTitleModal();
}}
sx={{
width: 32,
height: 32,
width: 28,
height: 28,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
color: '#fff',
boxShadow: '0 6px 12px rgba(127,90,240,0.25)',
boxShadow: '0 4px 10px rgba(127,90,240,0.25)',
cursor: 'pointer',
opacity: 0,
pointerEvents: 'none',
@@ -231,7 +465,7 @@ const BookPages: React.FC<BookPagesProps> = ({
gap: 3,
}}
>
<Box sx={{ position: 'relative', '&:hover .left-image-actions': { opacity: 1, pointerEvents: 'auto' } }}>
<Box sx={{ position: 'relative' }}>
{showAnimatedVideo ? (
<Box
sx={{
@@ -264,7 +498,6 @@ const BookPages: React.FC<BookPagesProps> = ({
</Box>
) : hasImage ? (
<>
{/* Removed 'Scene Illustration' heading for cleaner look */}
<Box
sx={{
width: '100%',
@@ -294,190 +527,6 @@ const BookPages: React.FC<BookPagesProps> = ({
}}
onError={onImageError}
/>
<Box
className="left-image-actions"
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
flexDirection: 'column',
gap: 1,
opacity: 0,
pointerEvents: 'none',
transition: 'opacity 0.2s ease',
zIndex: 5,
alignItems: 'flex-end',
}}
>
<Tooltip title="Edit scene image prompt">
<Box
role="button"
aria-label="Edit scene image"
onClick={(e) => { e.stopPropagation(); onOpenImageModal(); }}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 8px 16px rgba(127,90,240,0.3)',
color: 'white',
cursor: 'pointer',
}}
>
<EditNoteIcon />
</Box>
</Tooltip>
{hasImage && onAnimateScene && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-5s',
operation_type: 'scene_animation',
actual_provider_name: 'wavespeed',
}}
label="Animate Scene"
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1f8a70 0%, #32d9c8 100%)',
boxShadow: '0 8px 16px rgba(31,138,112,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #1a7a60 0%, #2dc9b8 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
)}
{hasImage && hasAudio && onAnimateWithVoiceover && (
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'wavespeed-ai/infinitetalk',
operation_type: 'scene_animation_voiceover',
actual_provider_name: 'wavespeed',
}}
label="Animate with Voiceover"
variant="contained"
size="small"
startIcon={<GraphicEqIcon />}
showCost
checkOnHover
checkOnMount={false}
onClick={onAnimateWithVoiceover}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #733dd9 0%, #bb86fc 100%)',
boxShadow: '0 8px 16px rgba(115,61,217,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #6030ba 0%, #a974f1 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
)}
{resumeInfo && onResumeScene && (
<Tooltip
title={resumeInfo.message || 'Resume animation download (no extra cost)'}
placement="left"
>
<Box
onClick={(e) => {
e.stopPropagation();
}}
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
>
<OperationButton
operation={{
provider: 'video',
model: 'kling-v2.5-turbo-std-resume',
operation_type: 'scene_animation_resume',
actual_provider_name: 'wavespeed',
}}
label="Resume Animation"
variant="contained"
size="small"
startIcon={<ReplayIcon />}
showCost={false}
checkOnHover={false}
checkOnMount={false}
onClick={onResumeScene}
disabled={isAnimatingScene}
sx={{
minWidth: 'auto',
padding: '8px',
width: 40,
height: 40,
borderRadius: '50%',
background: 'linear-gradient(135deg, #b35c1e 0%, #f5a623 100%)',
boxShadow: '0 8px 16px rgba(179,92,30,0.35)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #9c511a 0%, #e1911c 100%)',
},
'& .MuiButton-startIcon': {
margin: 0,
},
'& .MuiButton-label': {
display: 'none',
},
}}
tooltipPlacement="left"
/>
</Box>
</Tooltip>
)}
</Box>
</Box>
</>
) : (
@@ -590,6 +639,8 @@ const BookPages: React.FC<BookPagesProps> = ({
<OutlineHoverActions
onEdit={onOpenEditModal}
onImprove={onOpenEditModal}
onRefineAnime={onRefineAnimeScene}
isRefiningAnime={isRefiningAnimeScene}
/>
<Box sx={{ flex: 1, overflowY: 'auto', pt: { xs: 1, md: 2 } }}>
<Box className="chip-actions" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.2s ease' }}>
@@ -684,4 +735,3 @@ const BookPages: React.FC<BookPagesProps> = ({
};
export default BookPages;

View File

@@ -17,6 +17,7 @@ interface ImageEditModalProps {
imageWidth?: number;
imageHeight?: number;
imageModel?: string | null;
onOpenAdvancedSettings?: (prompt: string) => void;
}
const ImageEditModal: React.FC<ImageEditModalProps> = ({
@@ -31,6 +32,7 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({
imageWidth = 1024,
imageHeight = 1024,
imageModel,
onOpenAdvancedSettings,
}) => {
const [isRegenerating, setIsRegenerating] = React.useState(false);
const [regenerateError, setRegenerateError] = React.useState<string | null>(null);
@@ -133,7 +135,6 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({
<Divider sx={{ my: 1 }} />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{/* AI Prompt Optimizer */}
<Button
variant="outlined"
size="medium"
@@ -145,7 +146,6 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({
{isOptimizing ? 'Optimizing...' : 'AI Prompt Optimizer'}
</Button>
{/* Regenerate Scene - Active with cost estimation */}
{onRegenerate && (
<OperationButton
operation={{
@@ -168,6 +168,20 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({
sx={{ flex: 1, minWidth: '200px' }}
/>
)}
{onOpenAdvancedSettings && (
<Button
variant="text"
size="medium"
onClick={() => {
if (!value.trim() || isOptimizing || isRegenerating) return;
onOpenAdvancedSettings(value.trim());
}}
disabled={isOptimizing || isRegenerating || !value.trim()}
sx={{ minWidth: '200px' }}
>
Advanced image settings
</Button>
)}
</Box>
</Box>
</DialogContent>

View File

@@ -100,7 +100,7 @@ const OutlineActionsBar: React.FC<OutlineActionsBarProps> = ({
disabled={!canContinue}
sx={primaryButtonSx}
>
Continue to Writing
Generate Story
</Button>
</Box>
);

View File

@@ -2,15 +2,20 @@ import React from 'react';
import { Box, Tooltip } from '@mui/material';
import EditNoteIcon from '@mui/icons-material/EditNote';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
interface OutlineHoverActionsProps {
onEdit: () => void;
onImprove: () => void;
onRefineAnime?: () => void;
isRefiningAnime?: boolean;
}
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({
onEdit,
onImprove,
onRefineAnime,
isRefiningAnime,
}) => {
return (
<Box
@@ -74,6 +79,35 @@ const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({
<TipsAndUpdatesIcon />
</Box>
</Tooltip>
{onRefineAnime && (
<Tooltip title={isRefiningAnime ? "Refining scene with anime bible..." : "Refine scene (anime bible-aware)"}>
<Box
role="button"
aria-label="Refine scene (anime)"
onClick={(e) => {
e.stopPropagation();
if (!isRefiningAnime) {
onRefineAnime();
}
}}
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #111827 0%, #4b5563 50%, #9ca3af 100%)',
boxShadow: '0 8px 16px rgba(15,23,42,0.45)',
color: 'white',
cursor: isRefiningAnime ? 'default' : 'pointer',
opacity: isRefiningAnime ? 0.7 : 1,
}}
>
<AutoFixHighIcon />
</Box>
</Tooltip>
)}
</Box>
);
};

View File

@@ -19,11 +19,11 @@ export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
{/* Persona */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Persona"
label="Define the author's voice, style, and perspective that will guide the story's narrative"
value={state.persona}
onChange={(e) => state.setPersona(e.target.value)}
placeholder="Describe the author persona (e.g., 'A fantasy writer who loves intricate world-building')"
helperText="Define the author's voice, style, and perspective that will guide the story's narrative"
helperText="Describe who is telling this story and how it should feel to read."
required
multiline
rows={2}
@@ -38,6 +38,11 @@ export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
],
}}
/>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="outlined" size="small">
Enhance Persona
</Button>
</Box>
</Grid>
{/* Story Setting */}
@@ -63,6 +68,13 @@ export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
}}
/>
</Grid>
<Grid item xs={12}>
<Box sx={{ mt: -1, mb: 1, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="outlined" size="small">
Enhance Story Settings
</Button>
</Box>
</Grid>
{/* Characters */}
<Grid item xs={12}>

View File

@@ -77,3 +77,46 @@ export const STORY_IDEA_PLACEHOLDERS = [
"A retired space explorer returns to their home planet after 50 years, only to find it has been transformed into a utopian society that erases all traces of the past. They must uncover the truth about what happened while avoiding the watchful eyes of the perfect world they helped create.",
];
export const STORY_IDEA_PLACEHOLDERS_BY_COMBINATION: Record<string, string[]> = {
"marketing:product_story": [
"Tell the story of a busy marketer who discovers your tool on a late night, runs their first A/B test in minutes, and wakes up to a dashboard that finally makes sense to their whole team.",
"Describe a customer who was drowning in spreadsheets before your product automated their reporting, freeing them to focus on creative campaigns that grew revenue.",
"Share how a small agency used your platform to win a bigger client by showing a clear, story-driven campaign plan powered by your analytics.",
],
"marketing:brand_manifesto": [
"Write a brand manifesto for a company that believes thoughtful, long-form content can still win in a world of short attention spans.",
"Describe a brand that sees storytelling as infrastructure, not decoration, and wants every article, email, and video to feel like chapter in a larger narrative.",
"Create a manifesto for a founder-led brand that wants to sound confident but humble, optimistic but realistic, in every story it tells.",
],
"marketing:founder_story": [
"Tell the story of a founder who built this product after years of frustration using clunky tools that never understood how real marketers work.",
"Describe how the founder went from freelancing at a kitchen table to building a small remote team around a shared belief in better creative workflows.",
"Write about the moment the founder realized their side project could become a real company after one early customer sent an emotional thank-you email.",
],
"marketing:customer_story": [
"Describe a customer who struggled to publish consistent content until they layered your product into their weekly planning ritual.",
"Tell the story of a skeptical customer who tried your platform for a single campaign, then gradually replaced three different tools after seeing the results.",
"Write about a customer who used your product to launch a new product line and turned a quiet list into an engaged community.",
],
"pure:short_fiction": [
"A commuter misses their usual train by seconds and steps onto a nearly empty carriage, where each passenger seems to know a different version of their life.",
"On an ordinary Tuesday, everyone in a small town wakes up with the same dream fresh in their mind and realizes it contains instructions.",
"A barista begins to notice that the same stranger appears in the background of every photo they have ever taken, even those from childhood.",
],
"pure:long_fiction": [
"Across several decades, follow three generations of one family whose small decisions around a single house slowly reshape an entire neighborhood.",
"Tell the intertwined stories of five strangers who unknowingly influence each others lives through a series of anonymous letters.",
"A researcher discovers a pattern in global news events that suggests someone is editing the timeline, but only on very small, personal scales.",
],
"pure:anime_fiction": [
"In a floating city powered by forgotten songs, a quiet student discovers they can hear the original melodies and must decide whether to restore or rewrite them.",
"A shy mechanic repairs battle mechs that secretly contain fragments of their pilots memories, and one day hears a voice speak back.",
"Every night at midnight, the neon signs in a dense anime metropolis rearrange to spell a new prophecy that only one teenager seems able to read.",
],
"pure:experimental_fiction": [
"Write a story told entirely through error messages on a failing smart home device that has begun to care about its owners.",
"Describe a city where every conversation leaves behind a visible echo that lingers in the air for days, overlapping into unreadable noise.",
"A retired space explorer returns to their home planet after 50 years, only to find it has been transformed into a utopian society that erases all traces of the past.",
],
};

View File

@@ -1,14 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../../api/client';
import { StoryParametersSection } from './StoryParametersSection';
import { StoryConfigurationSection } from './StoryConfigurationSection';
import { FeatureCheckboxesSection } from './FeatureCheckboxesSection';
import { GenerationSettingsSection } from './GenerationSettingsSection';
import { AIStorySetupModal } from './AIStorySetupModal';
// TODO: Reintroduce FeatureCheckboxesSection and GenerationSettingsSection in a later
// publishing/campaign configuration phase (after Outline/Writing), so they feel like
// output configuration rather than part of the initial story setup step.
import { textFieldStyles, paperStyles } from './styles';
import { AUDIENCE_AGE_GROUPS } from './constants';
import { StorySetupProps, CustomValuesState, CustomValuesSetters } from './types';
@@ -17,7 +16,6 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
const [isRegeneratingPremise, setIsRegeneratingPremise] = useState(false);
const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// Track custom values from AI-generated options
const [customWritingStyles, setCustomWritingStyles] = useState<string[]>([]);
@@ -49,6 +47,10 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
const request = state.getRequest();
const response = await storyWriterApi.generateOutline(state.premise, request);
if (response.anime_bible) {
state.setAnimeBible(response.anime_bible);
}
if (response.success && response.outline) {
if (response.is_structured && Array.isArray(response.outline)) {
const scenes = response.outline as StoryScene[];
@@ -171,26 +173,57 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
return (
<>
<Paper sx={paperStyles}>
<Paper sx={paperStyles}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'flex-start', md: 'center' },
gap: 3,
mb: 4,
}}
>
<Box>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#1A1611' }}>
Story Setup
</Typography>
<Typography variant="body2" sx={{ color: '#5D4037' }}>
Configure your story parameters and premise. Fill in the required fields and click "Generate Outline" to
continue.
</Typography>
Story Studio Setup
</Typography>
<Typography variant="body2" sx={{ color: '#5D4037', mb: 1.5 }}>
Configure your core story parameters and premise. These choices will guide outline and writing in the next phases.
</Typography>
{(() => {
const modeLabel =
state.storyMode === 'marketing'
? 'Non-fiction'
: state.storyMode === 'pure'
? 'Fiction'
: null;
let templateLabel: string | null = null;
if (state.storyMode === 'marketing') {
templateLabel =
state.storyTemplate === 'product_story'
? 'Product Story'
: state.storyTemplate === 'brand_manifesto'
? 'Brand Manifesto'
: state.storyTemplate === 'founder_story'
? 'Founder Story'
: state.storyTemplate === 'customer_story'
? 'Customer Story'
: null;
}
if (!modeLabel && !templateLabel) return null;
return (
<Typography variant="body2" sx={{ color: '#374151', mt: 0.5 }}>
You&apos;re setting up a {modeLabel || 'Story'}
{templateLabel ? ` · ${templateLabel}` : ''}. You can fine-tune details later in the Outline and Writing phases.
</Typography>
);
})()}
</Box>
<FeatureCheckboxesSection state={state} layout="inline" />
<Box sx={{ display: 'flex', alignItems: 'center' }} />
</Box>
{error && (
@@ -199,64 +232,26 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
</Alert>
)}
<Box sx={{ mb: 4 }}>
<Button
variant="contained"
size="large"
startIcon={<AutoAwesomeIcon />}
onClick={() => setIsModalOpen(true)}
sx={{
mb: 1,
px: 4,
py: 1.5,
borderRadius: '999px',
textTransform: 'none',
fontWeight: 600,
background: 'linear-gradient(135deg, #7F5AF0 0%, #2CB67D 100%)',
boxShadow: '0 12px 24px rgba(127, 90, 240, 0.3)',
'&:hover': {
background: 'linear-gradient(135deg, #6c4cd4 0%, #24a26f 100%)',
boxShadow: '0 14px 30px rgba(127, 90, 240, 0.35)',
},
}}
>
Generate Story Setup with Alwrity AI
</Button>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Let Alwrity AI craft a cohesive persona, setting, and premise instantly.
</Typography>
</Box>
<Grid container spacing={4}>
<Grid item xs={12} md={8}>
<Grid container spacing={3}>
<StoryParametersSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
isRegeneratingPremise={isRegeneratingPremise}
onRegeneratePremise={handleRegeneratePremise}
/>
<Grid item xs={12}>
<Grid container spacing={3}>
<StoryParametersSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
isRegeneratingPremise={isRegeneratingPremise}
onRegeneratePremise={handleRegeneratePremise}
/>
<StoryConfigurationSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
normalizedAudienceAgeGroup={normalizedAudienceAgeGroup}
/>
<StoryConfigurationSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
normalizedAudienceAgeGroup={normalizedAudienceAgeGroup}
/>
</Grid>
</Grid>
<Grid item xs={12} md={4}>
<Box
sx={{
position: { md: 'sticky' },
top: { md: 16 },
}}
>
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
</Box>
</Grid>
</Grid>
</Grid>
{/* Generate Button */}
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
@@ -285,13 +280,6 @@ const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
</Button>
</Box>
</Paper>
<AIStorySetupModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
state={state}
customValuesSetters={customValuesSetters}
/>
</>
);
};

View File

@@ -168,10 +168,12 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
const loadImage = async () => {
try {
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
const cleanUrl = currentSceneImageUrl.split('?')[0];
// Use relative URL path directly (aiApiClient will add base URL and auth)
const imageUrl = currentSceneImageUrl.startsWith('/')
? currentSceneImageUrl
: `/${currentSceneImageUrl}`;
const imageUrl = cleanUrl.startsWith('/')
? cleanUrl
: `/${cleanUrl}`;
// Use aiApiClient to get authenticated response with blob
const response = await aiApiClient.get(imageUrl, {
responseType: 'blob',
@@ -291,6 +293,23 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
}
}, [storySections.length]);
const lastSavedWordCountRef = React.useRef<number | null>(null);
useEffect(() => {
if (!state.projectId) {
return;
}
if (!state.storyContent || !state.storyContent.trim()) {
return;
}
const wordCount = state.storyContent.split(/\s+/).filter((word) => word.length > 0).length;
if (lastSavedWordCountRef.current === wordCount) {
return;
}
lastSavedWordCountRef.current = wordCount;
state.saveProjectToDb();
}, [state.projectId, state.storyContent, state.saveProjectToDb, state]);
const handlePrevPage = () => {
if (canGoPrev) {
setPageDirection(-1);
@@ -305,7 +324,7 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
}
};
const handleGenerateStart = async () => {
const handleGenerateStart = React.useCallback(async () => {
if (!state.premise || (!state.outline && !state.outlineScenes)) {
setError('Please generate a premise and outline first');
return;
@@ -362,7 +381,17 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
} finally {
setIsGenerating(false);
}
};
}, [state]);
useEffect(() => {
if (state.autoGenerateOnWriting && !state.storyContent && !isGenerating) {
const run = async () => {
await handleGenerateStart();
state.setAutoGenerateOnWriting(false);
};
run();
}
}, [state.autoGenerateOnWriting, state.storyContent, isGenerating, handleGenerateStart, state]);
const handleContinue = async () => {
if (!state.premise || (!state.outline && !state.outlineScenes) || !state.storyContent) {
@@ -809,9 +838,11 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
) : (
<Box>
<Alert severity="info" sx={{ mb: 3 }}>
{state.premise && (state.outline || state.outlineScenes)
? 'Click "Generate Story" to start writing your story.'
: 'Please generate a premise and outline first.'}
{!state.premise || (!state.outline && !state.outlineScenes)
? 'Please generate a premise and outline first.'
: state.autoGenerateOnWriting || isGenerating
? 'Generating your story now...'
: 'Click "Generate Story" to start writing your story.'}
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button

View File

@@ -0,0 +1,356 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert,
Box,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
alpha,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import RefreshIcon from "@mui/icons-material/Refresh";
import AutoStoriesIcon from "@mui/icons-material/AutoStories";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import DeleteIcon from "@mui/icons-material/Delete";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../PodcastMaker/ui";
import {
storyWriterApi,
StoryProjectSummary,
StoryProjectListResponse,
} from "../../services/storyWriterApi";
const glassyCardSx = {
borderRadius: 3,
border: "1px solid rgba(255,255,255,0.08)",
background: alpha("#020617", 0.8),
backdropFilter: "blur(24px)",
};
interface StoryProjectListProps {
onSelectProject?: (projectId: string) => void;
}
export function StoryProjectList({ onSelectProject }: StoryProjectListProps) {
const navigate = useNavigate();
const [projects, setProjects] = useState<StoryProjectSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
useEffect(() => {
loadProjects();
}, []);
async function loadProjects() {
try {
setLoading(true);
setError(null);
const response: StoryProjectListResponse = await storyWriterApi.listStoryProjects({
order_by: "updated_at",
});
setProjects(response.projects);
} catch (err) {
setError("Failed to load story projects. Please try again.");
} finally {
setLoading(false);
}
}
async function handleDelete(projectId: string) {
try {
setLoading(true);
await storyWriterApi.deleteStoryProject(projectId);
setProjects((prev) => prev.filter((p) => p.project_id !== projectId));
} catch (err) {
setError("Failed to delete project. Please try again.");
} finally {
setLoading(false);
setDeleteDialogOpen(false);
setProjectToDelete(null);
}
}
async function handleToggleFavorite(projectId: string, current: boolean) {
try {
const updated = await storyWriterApi.toggleStoryProjectFavorite(projectId);
setProjects((prev) =>
prev.map((p) => (p.project_id === projectId ? { ...p, is_favorite: updated.is_favorite } : p))
);
} catch (err) {
setError("Failed to update favorites. Please try again.");
}
}
function getStatusLabel(status: string | null | undefined) {
if (!status) return "Draft";
return status.charAt(0).toUpperCase() + status.slice(1);
}
function getStatusColor(status: string | null | undefined) {
if (status === "completed") return "success";
if (status === "writing") return "primary";
if (status === "outline") return "secondary";
return "default";
}
const filteredProjects = useMemo(() => {
if (!searchQuery) return projects;
return projects.filter((project) => {
const titleMatch =
project.title &&
project.title.toLowerCase().includes(searchQuery.toLowerCase());
const idMatch = project.project_id
.toLowerCase()
.includes(searchQuery.toLowerCase());
return titleMatch || idMatch;
});
}, [projects, searchQuery]);
return (
<Box
sx={{
minHeight: "100vh",
background: "linear-gradient(135deg, #020617 0%, #0b1120 40%, #020617 100%)",
p: { xs: 2, md: 4 },
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: "auto",
borderRadius: 4,
border: "1px solid rgba(148,163,184,0.35)",
background: alpha("#020617", 0.85),
backdropFilter: "blur(28px)",
p: { xs: 3, md: 4 },
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
background: "linear-gradient(135deg, #38bdf8 0%, #a855f7 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<AutoStoriesIcon fontSize="large" />
My Story Projects
</Typography>
<Typography variant="body2" color="text.secondary">
Resume your stories or start a new one
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
Refresh
</SecondaryButton>
<PrimaryButton onClick={() => navigate("/story-writer")} startIcon={<PlayArrowIcon />}>
New Story
</PrimaryButton>
</Stack>
</Stack>
<TextField
fullWidth
placeholder="Search by title or project id..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <SearchIcon sx={{ color: "rgba(148,163,184,0.9)", mr: 1 }} />,
}}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(148,163,184,0.4)" },
"&:hover fieldset": { borderColor: "rgba(148,163,184,0.7)" },
},
}}
/>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{loading && (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
)}
{!loading && filteredProjects.length === 0 && (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2} alignItems="center" sx={{ p: 4 }}>
<Typography variant="h6" color="text.secondary">
{searchQuery ? "No projects match your search" : "No story projects yet"}
</Typography>
<PrimaryButton onClick={() => navigate("/story-writer")} startIcon={<PlayArrowIcon />}>
Create Your First Story
</PrimaryButton>
</Stack>
</GlassyCard>
)}
{!loading && filteredProjects.length > 0 && (
<Stack spacing={2}>
{filteredProjects.map((project) => (
<GlassyCard
key={project.project_id}
sx={{
...glassyCardSx,
cursor: "pointer",
"&:hover": {
borderColor: "rgba(56,189,248,0.55)",
transform: "translateY(-2px)",
},
transition: "all 0.2s",
}}
onClick={() => {
if (onSelectProject) {
onSelectProject(project.project_id);
} else {
navigate("/story-writer", { state: { projectId: project.project_id } });
}
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 1, color: "white" }}>
{project.title && project.title.trim().length > 0
? project.title
: project.project_id}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={getStatusLabel(project.status)}
size="small"
color={getStatusColor(project.status) as any}
sx={{ background: alpha("#38bdf8", 0.16), color: "#e0f2fe" }}
/>
{project.story_mode && (
<Chip
label={project.story_mode === "marketing" ? "Marketing" : "Fiction"}
size="small"
sx={{ background: alpha("#a855f7", 0.16), color: "#f5d0fe" }}
/>
)}
{project.story_template && (
<Typography variant="caption" color="text.secondary">
Template: {project.story_template}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
Updated {new Date(project.updated_at).toLocaleDateString()}
</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip
title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}
>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite(project.project_id, project.is_favorite);
}}
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(148,163,184,0.9)" }}
>
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Delete project">
<IconButton
onClick={(e) => {
e.stopPropagation();
setProjectToDelete(project.project_id);
setDeleteDialogOpen(true);
}}
sx={{ color: "rgba(148,163,184,0.9)" }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</Stack>
</GlassyCard>
))}
</Stack>
)}
</Stack>
</Paper>
<Dialog
open={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}
PaperProps={{
sx: {
background: alpha("#020617", 0.95),
backdropFilter: "blur(24px)",
border: "1px solid rgba(148,163,184,0.35)",
},
}}
>
<DialogTitle sx={{ color: "white" }}>Delete Story Project?</DialogTitle>
<DialogContent>
<Typography sx={{ color: "rgba(226,232,240,0.85)" }}>
Are you sure you want to delete this project? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<SecondaryButton
onClick={() => {
setDeleteDialogOpen(false);
setProjectToDelete(null);
}}
>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={() => {
if (projectToDelete) {
handleDelete(projectToDelete);
}
}}
sx={{
background: "#b91c1c",
"&:hover": {
background: "#991b1b",
},
}}
>
Delete
</PrimaryButton>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Box, Container, Typography, useTheme, Dialog, DialogTitle, DialogContent, IconButton } from '@mui/material';
import React, { useEffect, useState } from 'react';
import { Box, Container, Typography, useTheme, Dialog, DialogTitle, DialogContent, IconButton, Chip } from '@mui/material';
import { useStoryWriterState } from '../../hooks/useStoryWriterState';
import { useStoryWriterPhaseNavigation } from '../../hooks/useStoryWriterPhaseNavigation';
import StorySetup from './Phases/StorySetup';
@@ -11,11 +11,25 @@ import { MultimediaToolbar } from './components/MultimediaToolbar';
import { storyWriterApi } from '../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../api/client';
import CloseIcon from '@mui/icons-material/Close';
import LightbulbIcon from '@mui/icons-material/Lightbulb';
import { MultimediaSection } from './components/MultimediaSection';
import SaveIcon from '@mui/icons-material/Save';
import StoryWriterLanding from './StoryWriterLanding';
import { AIStorySetupModal } from './Phases/StorySetup/AIStorySetupModal';
import { useLocation, useNavigate } from 'react-router-dom';
import { SecondaryButton } from '../PodcastMaker/ui/SecondaryButton';
const createStoryProjectId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `story_${crypto.randomUUID()}`;
}
return `story_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
};
export const StoryWriter: React.FC = () => {
const theme = useTheme();
const location = useLocation();
const navigate = useNavigate();
// State management
const state = useStoryWriterState();
@@ -29,6 +43,30 @@ export const StoryWriter: React.FC = () => {
return window.localStorage.getItem('storywriter:landingDismissed') === 'true';
});
const [autoOpenSetupModal, setAutoOpenSetupModal] = useState(false);
const [isLandingSetupModalOpen, setIsLandingSetupModalOpen] = useState(false);
const [landingSetupMode, setLandingSetupMode] = useState<'marketing' | 'pure' | null>(null);
const [landingSetupTemplate, setLandingSetupTemplate] = useState<string | null>(null);
const [landingCustomWritingStyles, setLandingCustomWritingStyles] = useState<string[]>([]);
const [landingCustomStoryTones, setLandingCustomStoryTones] = useState<string[]>([]);
const [landingCustomNarrativePOVs, setLandingCustomNarrativePOVs] = useState<string[]>([]);
const [landingCustomAudienceAgeGroups, setLandingCustomAudienceAgeGroups] = useState<string[]>([]);
const [landingCustomContentRatings, setLandingCustomContentRatings] = useState<string[]>([]);
const [landingCustomEndingPreferences, setLandingCustomEndingPreferences] = useState<string[]>([]);
const [isDirectorOpen, setIsDirectorOpen] = useState(false);
const [isSavingProject, setIsSavingProject] = useState(false);
useEffect(() => {
const navState = location.state as { projectId?: string } | null;
const incomingProjectId = navState?.projectId;
if (incomingProjectId) {
state.loadProjectFromDb(incomingProjectId).catch((error: any) => {
console.error('Failed to load story project:', error);
});
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, location.pathname, state, navigate]);
// Phase navigation
const {
phases,
@@ -40,8 +78,9 @@ export const StoryWriter: React.FC = () => {
hasStoryContent: !!state.storyContent,
isComplete: state.isComplete,
});
// Reset handler
const handleReset = () => {
// Reset story state (this also clears localStorage)
state.resetState();
@@ -199,6 +238,34 @@ export const StoryWriter: React.FC = () => {
const hasStoryProgress = Boolean(state.premise || state.outline || state.storyContent);
const showLanding = !landingDismissed && !hasStoryProgress;
const hasAnimeBible = Boolean(state.animeBible);
const canSaveProject = Boolean(
state.premise || state.outline || state.outlineScenes || state.storyContent,
);
const handleSaveProject = async () => {
if (isSavingProject) {
return;
}
if (!canSaveProject) {
return;
}
setIsSavingProject(true);
try {
if (!state.projectId) {
const projectId = createStoryProjectId();
const title =
state.projectTitle ||
(state.premise && state.premise.trim().length > 0
? state.premise.trim().slice(0, 80)
: 'Untitled Story');
await state.initializeProject(projectId, title);
}
await state.saveProjectToDb();
} finally {
setIsSavingProject(false);
}
};
const handleLandingStart = () => {
setLandingDismissed(true);
@@ -208,6 +275,36 @@ export const StoryWriter: React.FC = () => {
navigateToPhase('setup');
};
const handleLandingSelectPath = (
mode: 'marketing' | 'pure',
template:
| 'product_story'
| 'brand_manifesto'
| 'founder_story'
| 'customer_story'
| 'short_fiction'
| 'long_fiction'
| 'anime_fiction'
| 'experimental_fiction'
| null,
) => {
state.setStoryMode(mode);
if (
mode === 'marketing' &&
(template === 'product_story' ||
template === 'brand_manifesto' ||
template === 'founder_story' ||
template === 'customer_story')
) {
state.setStoryTemplate(template);
} else {
state.setStoryTemplate(null);
}
setLandingSetupMode(mode);
setLandingSetupTemplate(template);
setIsLandingSetupModalOpen(true);
};
// Render phase content
const renderPhaseContent = () => {
switch (currentPhase) {
@@ -218,7 +315,13 @@ export const StoryWriter: React.FC = () => {
case 'writing':
return <StoryWriting state={state} onNext={() => navigateToPhase('export')} />;
case 'export':
return <StoryExport state={state} />;
return (
<StoryExport
state={state}
onSaveProject={handleSaveProject}
isSavingProject={isSavingProject}
/>
);
default:
return <StorySetup state={state} onNext={() => navigateToPhase('outline')} />;
}
@@ -234,7 +337,29 @@ export const StoryWriter: React.FC = () => {
}}
>
<Container maxWidth="xl">
<StoryWriterLanding onStart={handleLandingStart} />
<StoryWriterLanding onStart={handleLandingStart} onSelectPath={handleLandingSelectPath} />
<AIStorySetupModal
open={isLandingSetupModalOpen}
onClose={() => setIsLandingSetupModalOpen(false)}
state={state}
customValuesSetters={{
setCustomWritingStyles: setLandingCustomWritingStyles,
setCustomStoryTones: setLandingCustomStoryTones,
setCustomNarrativePOVs: setLandingCustomNarrativePOVs,
setCustomAudienceAgeGroups: setLandingCustomAudienceAgeGroups,
setCustomContentRatings: setLandingCustomContentRatings,
setCustomEndingPreferences: setLandingCustomEndingPreferences,
}}
originMode={landingSetupMode}
originTemplate={landingSetupTemplate}
onApplied={() => {
setLandingDismissed(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem('storywriter:landingDismissed', 'true');
}
navigateToPhase('setup');
}}
/>
</Container>
</Box>
);
@@ -245,7 +370,7 @@ export const StoryWriter: React.FC = () => {
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
padding: theme.spacing(4),
padding: theme.spacing(2, 4, 3),
position: 'relative',
'&::before': {
content: '""',
@@ -279,18 +404,27 @@ export const StoryWriter: React.FC = () => {
}}
>
{/* Header with Phase Navigation and Multimedia Toolbar */}
<Box sx={{ mb: 4 }}>
<Box sx={{ mb: 2, pt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Typography variant="h3" component="h1" gutterBottom sx={{ color: 'white' }}>
Story Writer
</Typography>
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Create compelling stories with AI assistance
</Typography>
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h5" component="h1" sx={{ color: 'white', fontWeight: 600, lineHeight: 1.2 }}>
Story Studio
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
Create fiction and non-fiction story campaigns with AI assistance
</Typography>
</Box>
{/* Compact Phase Navigation */}
<Box sx={{ flex: '1 1 auto', minWidth: { xs: '100%', md: '600px' }, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
flex: '1 1 auto',
minWidth: { xs: '100%', md: '600px' },
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-end',
}}
>
<Box sx={{ flex: 1 }}>
<PhaseNavigation
phases={phases}
@@ -299,6 +433,40 @@ export const StoryWriter: React.FC = () => {
onReset={handleReset}
/>
</Box>
<Chip
icon={
<LightbulbIcon
sx={{
color: hasAnimeBible ? '#22c55e' : '#f97373',
}}
/>
}
label="Director"
variant={hasAnimeBible ? 'filled' : 'outlined'}
onClick={() => setIsDirectorOpen(true)}
sx={{
borderColor: hasAnimeBible ? '#22c55e' : '#f97373',
color: hasAnimeBible ? '#065f46' : '#7f1d1d',
bgcolor: hasAnimeBible ? 'rgba(16,185,129,0.12)' : 'transparent',
fontWeight: 500,
height: 32,
}}
/>
<SecondaryButton
onClick={handleSaveProject}
loading={isSavingProject}
startIcon={<SaveIcon />}
disabled={!canSaveProject}
ariaLabel="Save story project"
tooltip={
state.projectId
? 'Save latest story changes to My Projects'
: 'Save this story to My Projects'
}
sx={{ minWidth: 140 }}
>
{state.projectId ? 'Save Project' : 'Save to My Projects'}
</SecondaryButton>
{/* Multimedia Toolbar */}
<MultimediaToolbar
state={state}
@@ -313,7 +481,7 @@ export const StoryWriter: React.FC = () => {
</Box>
{/* Phase Content */}
<Box sx={{ mt: 4 }}>
<Box sx={{ mt: 2 }}>
{renderPhaseContent()}
</Box>
</Container>
@@ -334,6 +502,34 @@ export const StoryWriter: React.FC = () => {
<MultimediaSection state={state} />
</DialogContent>
</Dialog>
<Dialog
open={isDirectorOpen}
onClose={() => setIsDirectorOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Anime Story Bible</DialogTitle>
<DialogContent dividers>
{state.animeBible ? (
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
m: 0,
}}
>
{JSON.stringify(state.animeBible, null, 2)}
</Box>
) : (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No anime story bible is available yet. Generate an outline for an anime story to create one.
</Typography>
)}
</DialogContent>
</Dialog>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ import {
Typography,
Alert,
LinearProgress,
TextField,
MenuItem,
} from '@mui/material';
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
@@ -19,6 +21,24 @@ const logger = {
info: (message: string, ...args: any[]) => console.info(`[HdVideoSection] ${message}`, ...args),
};
const VIDEO_MODEL_OPTIONS = [
{
id: 'huggingface:tencent/HunyuanVideo',
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
label: 'HuggingFace · HunyuanVideo',
},
{
id: 'wavespeed:hunyuan-video-1.5',
provider: 'wavespeed',
model: 'hunyuan-video-1.5',
label: 'WaveSpeed · HunyuanVideo-1.5',
},
];
const getDefaultVideoOptionId = (storyMode: 'marketing' | 'pure') =>
storyMode === 'marketing' ? 'huggingface:tencent/HunyuanVideo' : 'wavespeed:hunyuan-video-1.5';
interface HdVideoSectionProps {
state: ReturnType<typeof useStoryWriterState>;
error: string | null;
@@ -30,6 +50,9 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
const [hdVideoProgress, setHdVideoProgress] = useState(0);
const [hdVideoMessage, setHdVideoMessage] = useState<string>('');
const [hdVideoPrompts, setHdVideoPrompts] = useState<Map<number, string>>(new Map());
const [selectedVideoOptionId, setSelectedVideoOptionId] = useState<string>(
getDefaultVideoOptionId(state.storyMode),
);
const [approvalModal, setApprovalModal] = useState<{
open: boolean;
@@ -42,6 +65,10 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
const processSceneRef = useRef<((sceneIndex: number) => Promise<void>) | null>(null);
const selectedVideoOption =
VIDEO_MODEL_OPTIONS.find((option) => option.id === selectedVideoOptionId) ||
VIDEO_MODEL_OPTIONS[0];
const handleGenerateHdVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
onError('Please generate a structured outline first');
@@ -98,8 +125,8 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
provider: selectedVideoOption.provider,
model: selectedVideoOption.model,
num_frames: 50,
guidance_scale: 7.5,
});
@@ -241,8 +268,8 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
scene_data: scene,
story_context: storyContext,
all_scenes: scenes,
provider: 'huggingface',
model: 'tencent/HunyuanVideo',
provider: selectedVideoOption.provider,
model: selectedVideoOption.model,
num_frames: 50,
guidance_scale: 7.5,
});
@@ -296,13 +323,32 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
return (
<>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
select
label="HD Video Model"
value={selectedVideoOptionId}
onChange={(e) => setSelectedVideoOptionId(e.target.value)}
size="small"
sx={{ maxWidth: 320 }}
disabled={
isGeneratingHdVideo ||
state.hdVideoGenerationStatus === 'awaiting_approval' ||
state.hdVideoGenerationStatus === 'generating'
}
>
{VIDEO_MODEL_OPTIONS.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.label}
</MenuItem>
))}
</TextField>
<OperationButton
operation={{
provider: 'video',
model: 'tencent/HunyuanVideo',
model: selectedVideoOption.model,
tokens_requested: 0,
operation_type: 'video_generation',
actual_provider_name: 'huggingface',
actual_provider_name: selectedVideoOption.provider,
}}
label="Generate HD Animation with AI"
variant="contained"

View File

@@ -0,0 +1,96 @@
import React from 'react';
import {
ImageGenerationModal,
ImageGenerationSettings,
DEFAULT_MODELS,
} from '../../shared/ImageGenerationModal';
import {
ImageStyle,
RenderingSpeed,
AspectRatio,
ImageModel,
} from '../../shared/ImageGenerationModal.types';
export interface StoryImageGenerationSettings {
prompt: string;
style: ImageStyle;
renderingSpeed: RenderingSpeed;
aspectRatio: AspectRatio;
model: ImageModel;
}
interface StoryImageGenerationModalProps {
open: boolean;
onClose: () => void;
onGenerate: (settings: StoryImageGenerationSettings) => void;
initialPrompt: string;
sceneTitle?: string;
storyMode?: 'marketing' | 'pure' | null;
initialStyle?: ImageStyle;
initialRenderingSpeed?: RenderingSpeed;
initialAspectRatio?: AspectRatio;
initialModel?: ImageModel;
isGenerating?: boolean;
}
export const StoryImageGenerationModal: React.FC<StoryImageGenerationModalProps> = ({
open,
onClose,
onGenerate,
initialPrompt,
sceneTitle,
storyMode = 'marketing',
initialStyle,
initialRenderingSpeed,
initialAspectRatio,
initialModel,
isGenerating = false,
}) => {
const resolvedDefaultModel: ImageModel =
initialModel ||
(storyMode === 'marketing' ? 'ideogram-v3-turbo' : 'qwen-image');
const resolvedStyle: ImageStyle =
initialStyle || (storyMode === 'marketing' ? 'Realistic' : 'Fiction');
const resolvedRenderingSpeed: RenderingSpeed =
initialRenderingSpeed || 'Quality';
const resolvedAspectRatio: AspectRatio =
initialAspectRatio || '16:9';
const handleGenerate = (settings: ImageGenerationSettings) => {
const storySettings: StoryImageGenerationSettings = {
prompt: settings.prompt,
style: settings.style,
renderingSpeed: settings.renderingSpeed,
aspectRatio: settings.aspectRatio,
model: settings.model || resolvedDefaultModel,
};
onGenerate(storySettings);
};
return (
<ImageGenerationModal
open={open}
onClose={onClose}
onGenerate={handleGenerate}
initialPrompt={initialPrompt}
isGenerating={isGenerating}
title="Scene Illustration Settings"
contextTitle={sceneTitle}
promptLabel="Image Prompt"
promptHelp="Describe the scene illustration. Include key visual elements, characters, mood, and style. The AI will use this along with your story context."
generateButtonLabel="Regenerate Image"
showModelSelection={true}
availableModels={DEFAULT_MODELS}
defaultModel={resolvedDefaultModel}
defaultStyle={resolvedStyle}
defaultRenderingSpeed={resolvedRenderingSpeed}
defaultAspectRatio={resolvedAspectRatio}
/>
);
};
export type { StoryImageGenerationModalProps };

View File

@@ -123,10 +123,11 @@ export const AvatarCard: React.FC<AvatarCardProps> = React.memo(({
<Box>
<OperationButton
operation={{
provider: 'image_generation',
provider: 'stability',
model: 'default',
operation_type: 'image_generation',
tokens_requested: 0,
actual_provider_name: 'wavespeed',
actual_provider_name: 'stability',
}}
label="Regenerate Avatar"
variant="outlined"

View File

@@ -738,6 +738,7 @@ export const PlanStep: React.FC<PlanStepProps> = React.memo(({
title="Select Avatar from Asset Library"
sourceModule={undefined}
allowFavoritesOnly={false}
showBrandAvatarShortcut
/>
</motion.div>
);

View File

@@ -55,17 +55,17 @@ function estimateYouTubeTokens(
durationType?: DurationType | string | null
): number {
const normalizedDuration = validateDurationType(durationType);
const baseEstimates = {
video_planning: {
shorts: 9000, // Includes scenes in one optimized call
medium: 6000, // Plan only
long: 7000, // Plan only (longer prompts)
shorts: 7000,
medium: 5000,
long: 8000,
},
scene_building: {
shorts: 0, // Already included in planning for shorts
medium: 6500, // Base generation + 1 batch enhancement
long: 10000, // Base generation + 2 batch enhancements
shorts: 0,
medium: 5000,
long: 9000,
},
};
@@ -112,13 +112,12 @@ export function buildVideoPlanningOperation(
durationType?: DurationType | string | null,
providerOverride?: string
): PreflightOperation {
// Default to gemini (most common provider)
// Backend will use actual provider from GPT_PROVIDER env var regardless
const provider = providerOverride || 'gemini';
const provider = providerOverride || 'huggingface';
const normalizedDuration = validateDurationType(durationType);
return {
provider: mapProviderToEnum(provider),
model: 'gemini-2.5-flash',
operation_type: 'video_planning',
tokens_requested: estimateYouTubeTokens('video_planning', normalizedDuration),
actual_provider_name: getActualProviderName(provider),
@@ -139,13 +138,14 @@ export function buildSceneBuildingOperation(
providerOverride?: string
): PreflightOperation {
const normalizedDuration = validateDurationType(durationType);
const provider = providerOverride || 'gemini';
const provider = providerOverride || 'huggingface';
// For shorts, scenes are included in planning, so no separate operation needed
if (normalizedDuration === 'shorts' && hasPlan) {
// Return minimal operation (scenes already generated)
return {
provider: mapProviderToEnum(provider),
model: 'gemini-2.5-flash',
operation_type: 'scene_building',
tokens_requested: 0, // Already included in planning
actual_provider_name: getActualProviderName(provider),
@@ -154,6 +154,7 @@ export function buildSceneBuildingOperation(
return {
provider: mapProviderToEnum(provider),
model: 'gemini-2.5-flash',
operation_type: 'scene_building',
tokens_requested: estimateYouTubeTokens('scene_building', normalizedDuration),
actual_provider_name: getActualProviderName(provider),
@@ -168,6 +169,7 @@ export function buildSceneBuildingOperation(
export function buildImageEditingOperation(): PreflightOperation {
return {
provider: 'image_edit',
model: 'default',
operation_type: 'image_editing',
tokens_requested: 0, // Image operations are not token-based
actual_provider_name: 'image_edit',

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Badge, IconButton, Menu, MenuItem, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Badge, IconButton, Menu, Typography, Box, Divider, Chip, Tooltip, List, ListItem, ListItemText, ListItemIcon, Button } from '@mui/material';
import { Notifications as NotificationsIcon, NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material';
import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
import { billingService } from '../../services/billingService';
@@ -54,7 +54,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
const getSchedulerStorageKey = (uid: string) => `scheduler_alerts_dismissed_${uid}`;
const loadSchedulerDismissed = (uid: string) => {
const loadSchedulerDismissed = useCallback((uid: string) => {
if (!uid) return new Set<string>();
try {
const stored = localStorage.getItem(getSchedulerStorageKey(uid));
@@ -67,7 +67,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
} catch {
return new Set<string>();
}
};
}, []);
const persistSchedulerDismissed = (uid: string, dismissed: Set<string>) => {
if (!uid) return;
@@ -89,7 +89,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
useEffect(() => {
if (!userId) return;
schedulerDismissedRef.current = loadSchedulerDismissed(userId);
}, [userId]);
}, [userId, loadSchedulerDismissed]);
// Fetch all alerts
const rebuildGroups = (alertList: Alert[]) => {
@@ -208,13 +208,18 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
useEffect(() => {
if (!userId) return;
fetchAlerts();
// Delay initial fetch slightly to ensure auth token getter is installed
const timeoutId = setTimeout(() => {
fetchAlerts();
}, 1000);
// Poll every 60 seconds
intervalRef.current = setInterval(() => {
fetchAlerts();
}, 60000);
return () => {
clearTimeout(timeoutId);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}

View File

@@ -29,6 +29,7 @@ import {
} from '@mui/icons-material';
import { useContentAssets, ContentAsset } from '../../hooks/useContentAssets';
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
import { getLatestBrandAvatar, AssetResponse as BrandAvatarResponse } from '../../api/brandAssets';
export interface AssetLibraryImageModalProps {
open: boolean;
@@ -37,6 +38,7 @@ export interface AssetLibraryImageModalProps {
title?: string;
sourceModule?: string | string[]; // Optional filter by source module(s) (e.g., 'youtube_creator', 'podcast_maker', or ['youtube_creator', 'podcast_maker'])
allowFavoritesOnly?: boolean; // Optional favorites-only filter toggle
showBrandAvatarShortcut?: boolean; // Optional shortcut to show latest onboarding brand avatar
}
/**
@@ -50,6 +52,7 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
title = 'Select Image from Asset Library',
sourceModule,
allowFavoritesOnly = false,
showBrandAvatarShortcut = false,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedAsset, setSelectedAsset] = useState<ContentAsset | null>(null);
@@ -57,6 +60,9 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
const [brandAvatar, setBrandAvatar] = useState<BrandAvatarResponse | null>(null);
const [brandAvatarLoading, setBrandAvatarLoading] = useState(false);
const [brandAvatarError, setBrandAvatarError] = useState<string | null>(null);
const pageSize = 24;
// Filter for images only
@@ -71,6 +77,48 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets(filters);
// Load latest brand avatar generated in onboarding (Step 4)
useEffect(() => {
if (!open || !showBrandAvatarShortcut) {
setBrandAvatar(null);
setBrandAvatarError(null);
setBrandAvatarLoading(false);
return;
}
let cancelled = false;
const loadBrandAvatar = async () => {
try {
setBrandAvatarLoading(true);
setBrandAvatarError(null);
const response = await getLatestBrandAvatar();
if (cancelled) return;
if (response.success && response.image_url) {
setBrandAvatar(response);
} else {
setBrandAvatar(null);
}
} catch (err: any) {
if (cancelled) return;
console.error('[AssetLibraryImageModal] Failed to load brand avatar:', err);
setBrandAvatar(null);
setBrandAvatarError('Failed to load brand avatar');
} finally {
if (!cancelled) {
setBrandAvatarLoading(false);
}
}
};
loadBrandAvatar();
return () => {
cancelled = true;
};
}, [open, showBrandAvatarShortcut]);
// Check if a URL requires authentication (internal API endpoints)
const isAuthenticatedUrl = useCallback((url: string): boolean => {
if (!url) return false;
@@ -172,6 +220,43 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
setSelectedAsset(asset);
}, []);
const handleBrandAvatarSelect = useCallback(() => {
if (!brandAvatar || !brandAvatar.image_url) return;
const now = new Date().toISOString();
const asset: ContentAsset = {
id: brandAvatar.asset_id ?? -1,
user_id: 'current_user',
asset_type: 'image',
source_module: 'brand_avatar_generator',
filename: brandAvatar.image_url,
file_url: brandAvatar.image_url,
file_path: undefined,
file_size: undefined,
mime_type: 'image/png',
title: 'Brand Avatar',
description: brandAvatar.prompt || 'Brand avatar from onboarding',
prompt: brandAvatar.prompt,
tags: ['brand_avatar', 'onboarding'],
asset_metadata: {
source: 'onboarding_step4',
},
provider: undefined,
model: undefined,
cost: 0,
generation_time: undefined,
is_favorite: false,
download_count: 0,
share_count: 0,
created_at: now,
updated_at: now,
};
onSelect(asset);
handleClose();
}, [brandAvatar, onSelect, handleClose]);
const handleFavoriteToggle = useCallback(
async (assetId: number, e: React.MouseEvent) => {
e.stopPropagation();
@@ -214,6 +299,80 @@ export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
</DialogTitle>
<DialogContent dividers sx={{ backgroundColor: '#f9fafb' }}>
{/* Brand Avatar Shortcut (from Onboarding Step 4) */}
{showBrandAvatarShortcut && (
<Box sx={{ mb: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#111827' }}>
Brand Avatar from Onboarding
</Typography>
{brandAvatarLoading && <CircularProgress size={18} />}
</Stack>
{brandAvatarError && (
<Alert severity="warning" sx={{ mb: 1 }}>
{brandAvatarError}
</Alert>
)}
{!brandAvatarLoading && !brandAvatar && !brandAvatarError && (
<Typography variant="body2" sx={{ color: '#6b7280' }}>
No brand avatar found. Create one in Step 4 of onboarding to see it here.
</Typography>
)}
{!brandAvatarLoading && brandAvatar && brandAvatar.image_url && (
<Card
sx={{
display: 'flex',
alignItems: 'center',
p: 1.5,
borderRadius: 2,
border: '1px solid #e5e7eb',
cursor: 'pointer',
mb: 1.5,
'&:hover': {
boxShadow: 3,
borderColor: '#9ca3af',
},
}}
onClick={handleBrandAvatarSelect}
>
<Box
sx={{
width: 64,
height: 64,
borderRadius: 2,
overflow: 'hidden',
mr: 2,
bgcolor: '#f3f4f6',
}}
>
<CardMedia
component="img"
image={brandAvatar.image_url}
alt="Brand Avatar"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#111827' }}>
Use Brand Avatar
</Typography>
<Typography variant="body2" sx={{ color: '#6b7280' }}>
Quickly apply the avatar you created during onboarding.
</Typography>
</Box>
</Card>
)}
</Box>
)}
{/* Search and Filters */}
<Box sx={{ mb: 3 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Box, Chip, Tooltip } from '@mui/material';
export interface LegendItem {
label: string;
icon?: React.ReactNode;
tooltip: string;
sx?: any;
}
interface ChipLegendProps {
items: LegendItem[];
sx?: any;
}
const ChipLegend: React.FC<ChipLegendProps> = ({ items, sx }) => {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, ...sx }}>
{items.map((item, idx) => (
<Tooltip key={`${item.label}-${idx}`} title={item.tooltip}>
<Chip icon={item.icon as any} label={item.label} size="small" sx={item.sx} />
</Tooltip>
))}
</Box>
);
};
export default ChipLegend;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, List, ListItem, ListItemText, Paper } from '@mui/material';
import { Info, Visibility, TrendingDown, BarChart } from '@mui/icons-material';
import ChipLegend from './ChipLegend';
interface Suggestion {
query: string;
impressions: number;
ctr: number;
position: number;
}
interface GscSuggestionsPanelProps {
suggestions: Suggestion[];
rangeDays: number;
onUseInWriter?: (s: Suggestion) => void;
onProposeMeta?: (s: Suggestion) => void;
formatNumber: (n: number) => string;
}
const GscSuggestionsPanel: React.FC<GscSuggestionsPanelProps> = ({ suggestions, rangeDays, onUseInWriter, onProposeMeta, formatNumber }) => {
const impTh = rangeDays <= 7 ? 100 : rangeDays <= 30 ? 500 : 1500;
const ctrTh = 2.5;
return (
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">GSC Suggestions (High Impressions Low CTR)</Typography>
<Tooltip title="Opportunities where many people saw your result but few clicked. Focus on improving titles and descriptions for these queries. CTR means clickthrough rate.">
<Info fontSize="small" color="action" />
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary">Top {suggestions.length} opportunities</Typography>
</Box>
<ChipLegend
items={[
{ label: 'Impressions', icon: <Visibility fontSize="small" />, tooltip: `Impressions ≥ ${impTh} are considered high visibility for this window.`, sx: { backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'Low CTR', icon: <TrendingDown fontSize="small" />, tooltip: `CTR ≤ ${ctrTh}% indicates low clickthrough. Improve titles/meta.`, sx: { backgroundImage: 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)', color: '#991b1b', border: '1px solid #fecaca', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'Avg Pos', icon: <BarChart fontSize="small" />, tooltip: 'Average position gives ranking context.', sx: { backgroundImage: 'linear-gradient(135deg, #ede9fe 0%, #eff6ff 100%)', color: '#4c1d95', border: '1px solid #ddd6fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
]}
/>
{suggestions.length === 0 ? (
<Typography variant="body2" color="text.secondary">No lowCTR queries found for this window.</Typography>
) : (
<List dense>
{suggestions.map((s, idx) => {
const ctrColor = s.ctr >= 3 ? '#065f46' : s.ctr >= 1 ? '#92400e' : '#7f1d1d';
const ctrBg = s.ctr >= 3 ? 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)' : s.ctr >= 1 ? 'linear-gradient(135deg, #fef3c7 0%, #fffbeb 100%)' : 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)';
return (
<ListItem key={`${s.query}-${idx}`} sx={{ px: 0, py: 0.5 }}>
<Paper elevation={0} sx={{ px: 1.25, py: 1, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Tooltip title={`Impressions ≥ ${impTh}, CTR ≤ ${ctrTh}% • This: ${formatNumber(s.impressions)} impressions, ${s.ctr.toFixed(1)}% CTR, position ${s.position.toFixed(1)}`}>
<ListItemText
primary={s.query}
primaryTypographyProps={{
variant: 'body2',
sx: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#111827',
fontWeight: 500,
},
}}
/>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
<Tooltip title="Impressions">
<Chip label={`${formatNumber(s.impressions)} imp`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="CTR">
<Chip label={`${s.ctr.toFixed(1)}% CTR`} size="small" sx={{ backgroundImage: ctrBg, color: ctrColor, border: '1px solid rgba(0,0,0,0.06)', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="Average position">
<Chip label={`pos ${s.position.toFixed(1)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ede9fe 0%, #eff6ff 100%)', color: '#4c1d95', border: '1px solid #ddd6fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" variant="outlined" sx={{ textTransform: 'none' }} onClick={() => onUseInWriter && onUseInWriter(s)}>Use in Writer</Button>
<Button size="small" variant="contained" sx={{ textTransform: 'none' }} onClick={() => onProposeMeta && onProposeMeta(s)}>Propose Title/Meta</Button>
</Box>
</Box>
</Paper>
</ListItem>
);
})}
</List>
)}
</CardContent>
</Card>
);
};
export default GscSuggestionsPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, Grid, List, ListItem, Paper } from '@mui/material';
import { Info, MouseOutlined, Visibility } from '@mui/icons-material';
import ChipLegend from './ChipLegend';
interface DeltaQuery {
query: string;
deltaClicks: number;
deltaImpressions: number;
}
interface RefreshQueuePanelProps {
risingQueries: DeltaQuery[];
decliningQueries: DeltaQuery[];
loading: boolean;
onRecompute: () => void;
formatNumber: (n: number) => string;
}
const RefreshQueuePanel: React.FC<RefreshQueuePanelProps> = ({ risingQueries, decliningQueries, loading, onRecompute, formatNumber }) => {
const hasNoData = risingQueries.length === 0 && decliningQueries.length === 0;
return (
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">Refresh Queue (Current vs Previous)</Typography>
<Tooltip title="Highlights topics gaining or losing traction compared to the previous window. Rising = more clicks or impressions; declining = fewer. Use this to refresh content.">
<Info fontSize="small" color="action" />
</Tooltip>
</Box>
<Button onClick={onRecompute} disabled={loading} variant="outlined" size="small" sx={{ textTransform: 'none' }}>
{loading ? 'Computing…' : 'Recompute'}
</Button>
</Box>
<ChipLegend
items={[
{ label: 'Δ Clicks', icon: <MouseOutlined fontSize="small" />, tooltip: 'Change in clicks vs previous period', sx: { backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%)', color: '#1e40af', border: '1px solid #bfdbfe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'Δ Impr', icon: <Visibility fontSize="small" />, tooltip: 'Change in impressions vs previous period', sx: { backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
]}
/>
{hasNoData ? (
<Typography variant="body2" color="text.secondary">No rising or declining queries detected.</Typography>
) : (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Rising Queries</Typography>
<List dense>
{risingQueries.map((q, i) => (
<ListItem key={`rise-${i}`} sx={{ px: 0, py: 0.5 }}>
<Paper elevation={0} sx={{ px: 1, py: 0.75, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#111827',
fontWeight: 500,
}}
>
{q.query}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
<Tooltip title="Additional clicks compared to previous window">
<Chip icon={<MouseOutlined fontSize="small" />} label={`+${formatNumber(Math.max(0, q.deltaClicks))}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="Additional impressions compared to previous window">
<Chip icon={<Visibility fontSize="small" />} label={`+${formatNumber(Math.max(0, q.deltaImpressions))}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%)', color: '#1e40af', border: '1px solid #bfdbfe', fontWeight: 700 }} />
</Tooltip>
</Box>
<Button size="small" variant="outlined" sx={{ textTransform: 'none' }}>Open in Writer</Button>
</Box>
</Paper>
</ListItem>
))}
</List>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Declining Queries</Typography>
<List dense>
{decliningQueries.map((q, i) => (
<ListItem key={`decl-${i}`} sx={{ px: 0, py: 0.5 }}>
<Paper elevation={0} sx={{ px: 1, py: 0.75, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #fff7ed 100%)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, width: '100%', justifyContent: 'space-between' }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#111827',
fontWeight: 500,
}}
>
{q.query}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, flexShrink: 0 }}>
<Tooltip title="Lost clicks compared to previous window">
<Chip icon={<MouseOutlined fontSize="small" />} label={`${formatNumber(q.deltaClicks)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)', color: '#991b1b', border: '1px solid #fecaca', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="Lost impressions compared to previous window">
<Chip icon={<Visibility fontSize="small" />} label={`${formatNumber(q.deltaImpressions)}`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #ffedd5 0%, #fff7ed 100%)', color: '#9a3412', border: '1px solid #fed7aa', fontWeight: 700 }} />
</Tooltip>
</Box>
<Button size="small" variant="outlined" color="warning" sx={{ textTransform: 'none' }}>Create Brief</Button>
</Box>
</Paper>
</ListItem>
))}
</List>
</Grid>
</Grid>
)}
</CardContent>
</Card>
);
};
export default RefreshQueuePanel;

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Card, CardContent, Box, Typography, Tooltip, Chip, Button, List, ListItem, ListItemText, Paper, IconButton } from '@mui/material';
import { Info, MouseOutlined, Visibility, TrendingUp, OpenInNew } from '@mui/icons-material';
import ChipLegend from './ChipLegend';
type Query = { query: string; clicks: number; impressions: number; ctr: number };
interface TopPagesInsightsPanelProps {
pages: Array<{ page: string; clicks: number; impressions: number; ctr: number; queries?: Query[] }>;
risingQueries: Array<{ query: string }>;
onOpenPage: (url: string) => void;
onCreateBrief: (page: string, queries: Query[]) => void;
formatNumber: (n: number) => string;
}
const TopPagesInsightsPanel: React.FC<TopPagesInsightsPanelProps> = ({ pages, risingQueries, onOpenPage, onCreateBrief, formatNumber }) => {
const risingSet = React.useMemo(() => new Set(risingQueries.map(r => String(r.query || '').toLowerCase())), [risingQueries]);
return (
<Card sx={{ mt: 2, bgcolor: '#ffffff !important', color: '#1f2937 !important', border: '1px solid #e5e7eb !important', boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1) !important' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">Top Pages Insights</Typography>
<Tooltip title="Your pages with the most traffic from Google in this window. Improve winners and link to related pages to spread authority.">
<Info fontSize="small" color="action" />
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary">Sorted by clicks</Typography>
</Box>
<ChipLegend
items={[
{ label: 'Clicks', icon: <MouseOutlined fontSize="small" />, tooltip: 'Total clicks in the selected date range', sx: { backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eef2ff 100%)', color: '#1e3a8a', border: '1px solid #c7d2fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'Impressions', icon: <Visibility fontSize="small" />, tooltip: 'Total impressions in the selected date range', sx: { backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'CTR', tooltip: 'Click-through rate', sx: { backgroundImage: 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)', color: '#065f46', border: '1px solid #86efac', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
{ label: 'Trending', icon: <TrendingUp fontSize="small" />, tooltip: 'Appears in Rising Queries', sx: { backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 } },
]}
/>
{(!pages || pages.length === 0) ? (
<Typography variant="body2" color="text.secondary">No top pages for this window.</Typography>
) : (
<List dense>
{pages.slice(0, 10).map((p, idx) => {
const clicks = Number(p.clicks || 0);
const impressions = Number(p.impressions || 0);
const ctr = Number(p.ctr || 0);
const ctrColor = ctr >= 3 ? '#065f46' : ctr >= 1 ? '#92400e' : '#7f1d1d';
const ctrBg = ctr >= 3 ? 'linear-gradient(135deg, #d1fae5 0%, #ecfdf5 100%)' : ctr >= 1 ? 'linear-gradient(135deg, #fef3c7 0%, #fffbeb 100%)' : 'linear-gradient(135deg, #fee2e2 0%, #fff1f2 100%)';
const hasTrending = Array.isArray(p.queries) && p.queries!.some(q => risingSet.has(String(q.query || '').toLowerCase()));
return (
<ListItem key={`${p.page || 'page'}-${idx}`} sx={{ px: 0, py: 0.75 }}>
<Paper elevation={0} sx={{ px: 1.25, py: 1, width: '100%', borderRadius: 2, border: '1px solid #e5e7eb', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)', transition: 'all .2s', '&:hover': { boxShadow: '0 6px 12px rgba(17,24,39,0.06)', transform: 'translateY(-1px)' } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%', justifyContent: 'space-between' }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Tooltip title={`Total clicks ${clicks}, total impressions ${impressions}, CTR ${ctr.toFixed(1)}% for the selected range`}>
<ListItemText
primary={p.page || '(unknown page)'}
primaryTypographyProps={{
variant: 'body2',
sx: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#111827',
fontWeight: 500,
},
}}
/>
</Tooltip>
{hasTrending && <Chip icon={<TrendingUp fontSize="small" />} label="Trending" size="small" sx={{ mt: 0.5, backgroundImage: 'linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%)', color: '#065f46', border: '1px solid #a7f3d0', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2, mr: 1, flexShrink: 0 }}>
<Tooltip title="Total clicks across the selected date range. Higher is better.">
<Chip icon={<MouseOutlined fontSize="small" />} label={`${formatNumber(clicks)} clicks`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #dbeafe 0%, #eef2ff 100%)', color: '#1e3a8a', border: '1px solid #c7d2fe', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="Total impressions across the selected date range. Indicates visibility in search results.">
<Chip icon={<Visibility fontSize="small" />} label={`${formatNumber(impressions)} imp`} size="small" sx={{ backgroundImage: 'linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)', color: '#0f172a', border: '1px solid #cbd5e1', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', fontWeight: 700 }} />
</Tooltip>
<Tooltip title="Click-through rate. Higher indicates titles/meta attract clicks for given impressions.">
<Chip label={`${ctr.toFixed(1)}% CTR`} size="small" sx={{ backgroundImage: ctrBg, color: ctrColor, border: '1px solid rgba(0,0,0,0.06)', boxShadow: '0 1px 2px rgba(0,0,0,0.04)', fontWeight: 700 }} />
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Tooltip title="Open page in new tab">
<IconButton
size="small"
onClick={() => onOpenPage(p.page)}
sx={{ color: '#4f46e5' }}
>
<OpenInNew fontSize="small" />
</IconButton>
</Tooltip>
<Button size="small" variant="contained" sx={{ textTransform: 'none' }} onClick={() => onCreateBrief(p.page, p.queries || [])}>Create Brief</Button>
</Box>
</Box>
</Paper>
</ListItem>
);
})}
</List>
)}
</CardContent>
</Card>
);
};
export default TopPagesInsightsPanel;