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