Files
ALwrity/frontend/src/components/PodcastMaker/AnalysisPanel.tsx

560 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, Button, IconButton, Popover, TextField, Tooltip } from "@mui/material";
import { Psychology as PsychologyIcon, Person as PersonIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Input as InputIcon, Groups as GroupsIcon, ListAlt as ListAltIcon, Lightbulb as TipsIcon, Article as ArticleIcon, AutoFixHigh as BibleIcon } from "@mui/icons-material";
import { PodcastAnalysis, PodcastEstimate, PodcastBible } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material";
import { aiApiClient } from "../../api/client";
import { InputsTab, AudienceTab, OutlineTab, EpisodeDetailsTab, TakeawaysTab, GuestTab } from "./AnalysisPanel/tabs";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string;
duration?: number;
speakers?: number;
voiceName?: string;
podcastMode?: "audio_only" | "video_only" | "audio_video";
avatarUrl?: string | null;
avatarPrompt?: string | null;
bible?: PodcastBible | null;
onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
onUpdateBible?: (updatedBible: PodcastBible) => void;
}
type TabId = 'inputs' | 'audience' | 'outline' | 'details' | 'takeaways' | 'guest';
interface TabConfig {
id: TabId;
label: string;
icon: React.ReactNode;
}
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,
voiceName,
podcastMode,
avatarUrl,
avatarPrompt,
bible,
onRegenerate,
onUpdateAnalysis,
onUpdateBible
}) => {
const [activeTab, setActiveTab] = useState<TabId>('inputs');
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const [bibleAnchorEl, setBibleAnchorEl] = useState<HTMLElement | null>(null);
// Edit states
const [isEditing, setIsEditing] = useState(false);
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
const [editedBible, setEditedBible] = useState<PodcastBible | null>(null);
const tabs: TabConfig[] = [
{ id: 'inputs', label: 'Your Inputs', icon: <InputIcon /> },
{ id: 'audience', label: 'Audience & Keywords', icon: <GroupsIcon /> },
{ id: 'outline', label: 'Outline', icon: <ListAltIcon /> },
{ id: 'details', label: 'Titles, Hook & CTA', icon: <ArticleIcon /> },
{ id: 'takeaways', label: 'Takeaways', icon: <TipsIcon /> },
{ id: 'guest', label: 'Guest Talking Points', icon: <PersonIcon /> },
];
const tabButtonStyles = (isActive: boolean) => ({
background: isActive
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "#f8fafc",
color: isActive ? "#fff" : "#475569",
border: isActive
? "none"
: "1px solid #e2e8f0",
borderRadius: 2.5,
px: 2.5,
py: 1.25,
fontSize: "0.8rem",
fontWeight: 600,
textTransform: "none" as const,
transition: "all 0.25s ease",
boxShadow: isActive
? "0 4px 12px rgba(102, 126, 234, 0.3)"
: "0 1px 2px rgba(0, 0, 0, 0.05)",
"&:hover": {
background: isActive
? "linear-gradient(135deg, #764ba2 0%, #667eea 100%)"
: "#e2e8f0",
transform: isActive ? "translateY(-1px)" : "none",
boxShadow: isActive
? "0 6px 16px rgba(102, 126, 234, 0.35)"
: "0 2px 4px rgba(0, 0, 0, 0.08)",
},
"&:active": {
transform: "translateY(0)",
},
});
// Sync editedAnalysis with analysis initially
useEffect(() => {
if (analysis && !editedAnalysis) {
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
}
}, [analysis, editedAnalysis]);
const handleSave = () => {
if (editedAnalysis && onUpdateAnalysis) {
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(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
setAvatarError(false);
return;
}
// Check if it's already a blob URL
if (avatarUrl.startsWith('blob:')) {
setAvatarBlobUrl(avatarUrl);
return;
}
// Check if it's an authenticated endpoint
const isAuthenticatedEndpoint = avatarUrl.includes('/api/podcast/images/') || avatarUrl.includes('/api/podcast/avatar/');
let currentBlobUrl: string | null = null;
if (isAuthenticatedEndpoint) {
setAvatarLoading(true);
setAvatarError(false);
const loadAvatarBlob = async () => {
try {
const response = await aiApiClient.get(avatarUrl, { responseType: 'blob' });
const blobUrl = URL.createObjectURL(response.data);
currentBlobUrl = blobUrl;
setAvatarBlobUrl(blobUrl);
setAvatarError(false);
} catch (error) {
console.error('[AnalysisPanel] Failed to load avatar as blob:', error);
// Fallback: try with query token
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${avatarUrl}?token=${encodeURIComponent(token)}`;
setAvatarBlobUrl(urlWithToken);
} else {
setAvatarError(true);
}
} catch (fallbackError) {
console.error('[AnalysisPanel] Fallback avatar loading failed:', fallbackError);
setAvatarError(true);
}
} finally {
setAvatarLoading(false);
}
};
loadAvatarBlob();
// Cleanup blob URL on unmount or when avatarUrl changes
return () => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
// Also cleanup any previous blob URL from state
setAvatarBlobUrl((prev) => {
if (prev && prev.startsWith('blob:') && prev !== currentBlobUrl) {
URL.revokeObjectURL(prev);
}
return null;
});
};
} else {
// Direct URL, use as-is
setAvatarBlobUrl(avatarUrl);
}
}, [avatarUrl]);
if (!analysis) return null;
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
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: "#111827",
}}
aria-label="analysis-panel"
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack direction="row" alignItems="center" spacing={2} flex={1}>
<Typography
variant="h6"
sx={{
color: "#1e293b",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
whiteSpace: "nowrap"
}}
>
<PsychologyIcon sx={{ color: "#4f46e5" }} />
AI Analysis
</Typography>
{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}>
{/* Bible Button */}
{bible && (
<Tooltip title="Podcast Bible - Hyper-personalized context">
<IconButton
onClick={(e) => setBibleAnchorEl(e.currentTarget)}
sx={{
bgcolor: bibleAnchorEl ? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" : "rgba(102, 126, 234, 0.1)",
border: "1px solid",
borderColor: bibleAnchorEl ? "transparent" : "rgba(102, 126, 234, 0.3)",
borderRadius: 2,
p: 1,
transition: "all 0.2s ease",
"&:hover": {
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
borderColor: "transparent",
},
}}
>
<BibleIcon sx={{ color: bibleAnchorEl ? "#fff" : "#667eea", fontSize: 20 }} />
</IconButton>
</Tooltip>
)}
{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)" }} />
{/* AI Futuristic Tab Navigation */}
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
startIcon={tab.icon}
sx={tabButtonStyles(activeTab === tab.id)}
>
{tab.label}
</Button>
))}
</Stack>
{/* Tab Content */}
<Box sx={{ minHeight: 300 }}>
{activeTab === 'inputs' && (
<InputsTab
idea={idea}
duration={duration}
speakers={speakers}
voiceName={voiceName}
podcastMode={podcastMode}
avatarUrl={avatarUrl}
avatarPrompt={avatarPrompt}
avatarBlobUrl={avatarBlobUrl}
avatarLoading={avatarLoading}
avatarError={avatarError}
/>
)}
{activeTab === 'audience' && (
<AudienceTab
analysis={currentAnalysis}
isEditing={isEditing}
editedAnalysis={editedAnalysis}
setEditedAnalysis={setEditedAnalysis}
handleRemoveKeyword={handleRemoveKeyword}
handleAddKeyword={handleAddKeyword}
handleRemoveTitle={handleRemoveTitle}
handleAddTitle={handleAddTitle}
handleUpdateOutline={handleUpdateOutline}
updateExaConfig={updateExaConfig}
/>
)}
{activeTab === 'outline' && (
<OutlineTab
analysis={currentAnalysis}
isEditing={isEditing}
onUpdateOutline={handleUpdateOutline}
/>
)}
{activeTab === 'details' && (
<EpisodeDetailsTab
analysis={currentAnalysis}
isEditing={isEditing}
handleRemoveTitle={handleRemoveTitle}
handleAddTitle={handleAddTitle}
/>
)}
{activeTab === 'takeaways' && (
<TakeawaysTab analysis={currentAnalysis} />
)}
{activeTab === 'guest' && (
<GuestTab analysis={currentAnalysis} />
)}
</Box>
{/* Bible Popover */}
<Popover
open={Boolean(bibleAnchorEl)}
anchorEl={bibleAnchorEl}
onClose={() => setBibleAnchorEl(null)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
sx: {
mt: 1,
maxWidth: 420,
borderRadius: 3,
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
border: "1px solid rgba(102, 126, 234, 0.3)",
boxShadow: "0 10px 40px rgba(102, 126, 234, 0.25)",
},
}}
>
<Box sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<BibleIcon sx={{ color: "#a78bfa", fontSize: 24 }} />
<Typography variant="h6" sx={{ color: "#fff", fontWeight: 700 }}>
Podcast Bible
</Typography>
<Tooltip title="Hyper-personalized context derived from your onboarding data. This grounds all research and script generation.">
<IconButton size="small" sx={{ ml: 'auto' }}>
<Typography variant="caption" sx={{ color: "#94a3b8" }}></Typography>
</IconButton>
</Tooltip>
</Stack>
{/* Host Persona */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(99, 102, 241, 0.1)", border: "1px solid rgba(99, 102, 241, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#a78bfa", fontWeight: 600, mb: 0.5, display: "block" }}>
Host Persona
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.host?.name || "Not set"} {bible?.host?.background || "No background"} {bible?.host?.vocal_style || "No style"}
</Typography>
</Box>
{/* Audience DNA */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(34, 197, 94, 0.1)", border: "1px solid rgba(34, 197, 94, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#22c55e", fontWeight: 600, mb: 0.5, display: "block" }}>
Audience DNA
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.audience?.expertise_level || "General"} {(bible?.audience?.interests || []).slice(0, 3).join(", ") || "Various interests"}
</Typography>
</Box>
{/* Brand DNA */}
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(249, 115, 22, 0.1)", border: "1px solid rgba(249, 115, 22, 0.2)" }}>
<Typography variant="caption" sx={{ color: "#f97316", fontWeight: 600, mb: 0.5, display: "block" }}>
Brand DNA
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem" }}>
{bible?.brand?.industry || "No industry"} {bible?.brand?.tone || "No tone"} {bible?.brand?.communication_style || "No style"}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.5)", textAlign: "center", fontSize: "0.7rem" }}>
Podcast Bible personalizes all AI generation for your unique voice
</Typography>
</Stack>
</Box>
</Popover>
</Stack>
</GlassyCard>
);
};