chore: bulk commit of local changes across blog writer, SEO dashboard, scheduler, docs-site, and frontend

This commit is contained in:
ajaysi
2026-06-05 12:40:04 +05:30
parent b894bc0abb
commit e54aaa7a3e
74 changed files with 5667 additions and 996 deletions

View File

@@ -292,14 +292,42 @@ export const getTasksNeedingIntervention = async (userId: string): Promise<TaskN
throw new Error('Failed to fetch tasks needing intervention');
}
return response.data.tasks || [];
return response.data.tasks || [];
} catch (error: any) {
console.error('Error fetching tasks needing intervention:', error);
throw new Error(
error.response?.data?.detail ||
error.message ||
error.response?.data?.detail ||
error.message ||
'Failed to fetch tasks needing intervention'
);
}
};
export interface OnboardingTask {
task_type: string;
label: string;
description: string;
frequency: string;
task_id: number;
website_url: string | null;
status: string;
status_label: string;
last_success: string | null;
last_failure: string | null;
next_execution: string | null;
failure_reason: string | null;
consecutive_failures: number;
}
export const getOnboardingTasks = async (userId: string): Promise<OnboardingTask[]> => {
try {
const response = await apiClient.get<{ success: boolean; tasks: OnboardingTask[]; count: number }>(
`/api/scheduler/onboarding-tasks/${userId}`
);
return response.data.tasks || [];
} catch (error: any) {
console.error('Error fetching onboarding tasks:', error);
return [];
}
};

View File

@@ -104,7 +104,8 @@ const BlogWriter: React.FC = () => {
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
handleContentSave,
restoreFromAsset
} = useBlogWriterState();
// SEO Manager - handles all SEO-related logic
@@ -275,6 +276,7 @@ const BlogWriter: React.FC = () => {
updatePhase,
loadAsset,
resetAsset,
asset,
} = useBlogAsset();
// Load blog asset passed via React Router state (from Asset Library)
const location = useLocation();
@@ -292,6 +294,7 @@ const BlogWriter: React.FC = () => {
loadAsset(assetIdFromState).then(loaded => {
if (!loaded) return;
saveLastAssetId(assetIdFromState);
restoreFromAsset(loaded);
debug.log('[BlogWriter] Loaded blog asset from navigation state', { asset_id: assetIdFromState, phase: loaded.phase });
});
} else {
@@ -302,6 +305,7 @@ const BlogWriter: React.FC = () => {
if (!isNaN(id)) {
loadAsset(id).then(loaded => {
if (loaded) {
restoreFromAsset(loaded);
debug.log('[BlogWriter] Restored last active blog', { asset_id: id, phase: loaded.phase });
} else {
// Asset was deleted or inaccessible — clear stale localStorage key
@@ -555,9 +559,13 @@ const BlogWriter: React.FC = () => {
const handleCachedContentComplete = useCallback((cachedSections: Record<string, string>) => {
if (cachedSections && Object.keys(cachedSections).length > 0) {
setSections(cachedSections);
debug.log('[BlogWriter] Cached content loaded into state', { sections: Object.keys(cachedSections).length });
setContentConfirmed(true);
debug.log('[BlogWriter] Cached content loaded into state, auto-confirmed', { sections: Object.keys(cachedSections).length });
setTimeout(() => {
navigateToPhaseRef.current?.('seo');
}, 0);
}
}, [setSections]);
}, [setSections, setContentConfirmed]);
// Phase action handlers for when CopilotKit is unavailable - extracted to usePhaseActionHandlers
const {

View File

@@ -151,11 +151,37 @@ export const PublishContent: React.FC<PublishContentProps> = ({
}
};
// Inject section images from localStorage into markdown so Wix can publish them
const enrichMarkdownWithImages = (markdown: string): string => {
try {
const outline = JSON.parse(localStorage.getItem('blog_outline') || '[]');
const images = JSON.parse(localStorage.getItem('blog_section_images') || '{}');
if (!outline.length || !Object.keys(images).length) return markdown;
let enriched = markdown;
for (const section of outline) {
const image = images[section.id];
if (!image) continue;
// Only inject URL-based images (http or /api/); skip base64 (too large for Wix API)
if (!image.startsWith('http') && !image.startsWith('/api/')) continue;
const heading = section.heading;
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(##\\s+${escapedHeading}\\n\\n)`);
enriched = enriched.replace(pattern, `$1![${heading}](${image})\n\n`);
}
return enriched;
} catch {
return markdown;
}
};
const handlePublishToWix = async () => {
const md = buildFullMarkdown();
const enrichedMd = enrichMarkdownWithImages(md);
setPublishResult(null);
setWixContentWarning(null);
const validation = validateWixContent(md);
const validation = validateWixContent(enrichedMd);
if (!validation.valid) {
setPublishResult({ platform: 'wix', success: false, message: validation.warning || 'Content validation failed.' });
return;
@@ -163,12 +189,11 @@ export const PublishContent: React.FC<PublishContentProps> = ({
if (validation.warning) {
setWixContentWarning(validation.warning);
}
const result = await publishToWix(md, seoMetadata, blogTitle);
const result = await publishToWix(enrichedMd, seoMetadata, blogTitle);
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.warning && result.success) {
setWixContentWarning(result.warning);
}
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.success) {
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}

View File

@@ -172,6 +172,8 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
fontSize: '14px',
lineHeight: '1.5',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
cursor: 'pointer'
}}
title="Click to edit title"
@@ -389,22 +391,25 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
</div>
<div style={{ display: 'grid', gap: '10px' }}>
{generatedTitles.map((title, index) => (
<button
key={`seo-${index}`}
onClick={() => handleTitleSelect(title)}
style={{
width: '100%',
padding: '16px 20px',
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
borderRadius: '12px',
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
cursor: 'pointer',
textAlign: 'left',
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4'
}}
<button
key={`seo-${index}`}
onClick={() => handleTitleSelect(title)}
style={{
width: '100%',
padding: '16px 20px',
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
borderRadius: '12px',
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
cursor: 'pointer',
textAlign: 'left',
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
e.currentTarget.style.backgroundColor = '#f9fafb';
@@ -477,7 +482,10 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4'
lineHeight: '1.4',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
interface ResearchProgressModalProps {
open: boolean;
@@ -397,27 +398,27 @@ const mapMessageToMeta = (message: { timestamp: string; message: string }): Mess
const stageStateCopy: Record<StageState, { label: string; color: string; background: string; border: string }> = {
upcoming: {
label: 'Pending',
color: '#6b7280',
background: '#f3f4f6',
color: '#9ca3af',
background: '#f9fafb',
border: '#e5e7eb'
},
active: {
label: 'In Progress',
color: '#2563eb',
background: '#eff6ff',
border: '#bfdbfe'
color: '#1d4ed8',
background: '#dbeafe',
border: '#93c5fd'
},
done: {
label: 'Completed',
color: '#047857',
background: '#ecfdf5',
border: '#bbf7d0'
background: '#d1fae5',
border: '#86efac'
},
error: {
label: 'Needs Attention',
color: '#b91c1c',
background: '#fee2e2',
border: '#fecaca'
border: '#fca5a5'
}
};
@@ -496,11 +497,24 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
}));
}, [error, normalizedStatus, processedMessages]);
const isRunning = !error && !completionStatuses.has(normalizedStatus);
if (!open) {
return null;
}
return (
<>
<style>{`
@keyframes researchPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.15); }
50% { box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); }
}
@keyframes researchShimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}</style>
<div
role="dialog"
aria-modal="true"
@@ -575,18 +589,26 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
marginTop: 14,
display: 'inline-flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
gap: 10,
padding: '8px 16px 8px 14px',
borderRadius: 999,
background: statusInfo.background,
color: statusInfo.color,
fontSize: 13,
fontWeight: 600,
border: `1px solid ${statusInfo.color}1A`
border: `1px solid ${statusInfo.color}33`,
animation: isRunning ? 'researchPulse 2s ease-in-out infinite' : undefined
}}
>
{isRunning && (
<CircularProgress
size={14}
thickness={6}
sx={{ color: statusInfo.color }}
/>
)}
<span>{statusInfo.label}</span>
<span style={{ fontSize: 12, color: '#475569', fontWeight: 500 }}>{statusInfo.description}</span>
<span style={{ fontSize: 12, color: '#64748b', fontWeight: 500 }}>{statusInfo.description}</span>
</div>
</div>
<button
@@ -610,16 +632,49 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
</div>
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
<div style={{ marginBottom: 20 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginBottom: 8
}}
>
<div
style={{
flex: 1,
height: 6,
borderRadius: 3,
background: '#e5e7eb',
overflow: 'hidden',
position: 'relative'
}}
>
<div
style={{
width: `${Math.round((stagesWithState.filter(s => s.state === 'done').length / stagesWithState.length) * 100)}%`,
height: '100%',
borderRadius: 3,
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
transition: 'width 0.5s ease'
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#64748b', whiteSpace: 'nowrap' }}>
{stagesWithState.filter(s => s.state === 'done').length}/{stagesWithState.length}
</span>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
marginBottom: 20
gap: 12
}}
>
{stagesWithState.map(stage => {
const copy = stageStateCopy[stage.state];
const isActive = stage.state === 'active';
return (
<div
key={stage.id}
@@ -630,7 +685,11 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
padding: '14px 16px',
background: copy.background,
border: `1px solid ${copy.border}`,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)'
boxShadow: isActive
? '0 0 0 1px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255,255,255,0.6)'
: 'inset 0 1px 0 rgba(255,255,255,0.6)',
animation: isActive ? 'researchPulse 2s ease-in-out infinite' : undefined,
transition: 'all 0.3s ease'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600, color: '#0f172a' }}>
@@ -638,11 +697,17 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
<span>{stage.label}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12.5, color: '#475569' }}>{stage.description}</div>
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color }}>{copy.label}</div>
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color, display: 'flex', alignItems: 'center', gap: 6 }}>
{isActive && (
<CircularProgress size={10} thickness={6} sx={{ color: copy.color }} />
)}
{copy.label}
</div>
</div>
);
})}
</div>
</div>
{latestMessage && (
<div
@@ -666,8 +731,13 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
gap: 16
}}
>
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a' }}>{latestMessage.title}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{latestMessage.timeLabel}</div>
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a', display: 'flex', alignItems: 'center', gap: 8 }}>
{latestMessage.tone === 'active' && isRunning && (
<CircularProgress size={14} thickness={6} sx={{ color: '#1d4ed8', flexShrink: 0 }} />
)}
{latestMessage.title}
</div>
<div style={{ fontSize: 12, color: '#64748b', flexShrink: 0 }}>{latestMessage.timeLabel}</div>
</div>
{latestMessage.subtitle && (
<div style={{ marginTop: 6, fontSize: 13.5, color: '#334155' }}>{latestMessage.subtitle}</div>
@@ -702,7 +772,8 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
}}
>
{processedMessages.length === 0 && (
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14 }}>
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
{isRunning && <CircularProgress size={12} thickness={6} sx={{ color: '#6b7280' }} />}
Awaiting progress updates
</div>
)}
@@ -764,6 +835,7 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
</div>
</div>
</div>
</>
);
};

View File

@@ -76,7 +76,12 @@ const TitleSelector: React.FC<TitleSelectorProps> = ({
{selectedTitle === title && (
<span style={{ color: '#1976d2', fontSize: '16px' }}></span>
)}
<span style={{ fontWeight: selectedTitle === title ? '600' : '400' }}>
<span style={{
fontWeight: selectedTitle === title ? '600' : '400',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}>
{title}
</span>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { getApiBaseUrl } from '../../utils/apiUrl';
import {
Box,
Paper,
@@ -48,6 +49,8 @@ import { AssetFilters as AssetFiltersComponent } from './AssetLibraryComponents/
import { AssetCard } from './AssetLibraryComponents/AssetCard';
import { AssetTableRow } from './AssetLibraryComponents/AssetTableRow';
const API_BASE_URL = getApiBaseUrl();
export const AssetLibrary: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -321,9 +324,10 @@ export const AssetLibrary: React.FC = () => {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(asset.file_url, { headers });
const response = await fetch(`${API_BASE_URL}/api/content-assets/${asset.id}/content`, { headers });
if (response.ok) {
const content = await response.text();
const data = await response.json();
const content = data.content || '';
setTextPreviews(prev => ({ ...prev, [asset.id]: { content, loading: false, expanded: false } }));
} else {
throw new Error('Failed to fetch text content');

View File

@@ -5,8 +5,11 @@ import {
Typography,
Chip,
Tooltip,
Divider,
LinearProgress,
Table,
TableBody,
TableCell,
TableRow,
} from '@mui/material';
import {
Topic as TopicIcon,
@@ -14,6 +17,18 @@ import {
Update as UpdateIcon,
Timeline as VelocityIcon,
Warning as WarningIcon,
Link as LinkIcon,
AltRoute as RedirectIcon,
Image as ImageIcon,
Language as UrlIcon,
Dns as RobotsIcon,
AccountTree as BudgetIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
Info as InfoIcon,
TrendingUp as TrendUpIcon,
TrendingDown as TrendDownIcon,
TrendingFlat as TrendFlatIcon,
} from '@mui/icons-material';
import { GlassCard } from '../../shared/styled';
@@ -21,24 +36,73 @@ interface AdvertoolsInsightsProps {
data: any;
}
const SeverityChip: React.FC<{ severity: string }> = ({ severity }) => {
const config: Record<string, { color: any; icon: any }> = {
critical: { color: 'error', icon: <ErrorIcon sx={{ fontSize: 14 }} /> },
warning: { color: 'warning', icon: <WarningIcon sx={{ fontSize: 14 }} /> },
info: { color: 'info', icon: <InfoIcon sx={{ fontSize: 14 }} /> },
};
const c = config[severity] || config.info;
return (
<Chip
label={severity}
size="small"
color={c.color}
icon={c.icon as any}
sx={{ height: 20, fontSize: '0.65rem', textTransform: 'capitalize' }}
/>
);
};
const TrendBadge: React.FC<{ trend: string }> = ({ trend }) => {
if (trend === 'increasing') return <TrendUpIcon sx={{ fontSize: 16, color: '#10b981' }} />;
if (trend === 'decreasing') return <TrendDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />;
return <TrendFlatIcon sx={{ fontSize: 16, color: '#f59e0b' }} />;
};
const ScoreBar: React.FC<{ value: number; label: string; max?: number }> = ({ value, label, max = 100 }) => {
const pct = Math.min((value / max) * 100, 100);
const color = pct >= 80 ? '#10b981' : pct >= 50 ? '#f59e0b' : '#ef4444';
return (
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>{label}</Typography>
<Typography variant="caption" sx={{ color: 'white', fontWeight: 600 }}>{value}</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 6,
borderRadius: 3,
bgcolor: 'rgba(255,255,255,0.06)',
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
}}
/>
</Box>
);
};
export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data }) => {
if (!data || (!data.augmented_themes?.length && !data.site_health?.total_urls)) {
if (!data || (!data.augmented_themes?.length && !data.site_health?.total_urls && !data.freshness?.freshness_score && !data.link_health?.total_links_found)) {
return null;
}
const { augmented_themes, site_health, last_audit, last_health_check, tasks, avg_word_count } = data;
const { augmented_themes, site_health, last_audit, last_health_check, tasks, avg_word_count,
freshness, link_health, redirect_audit, image_seo, url_structure, page_status,
robots_txt, crawl_budget } = data;
const getStatusDisplay = (taskType: string) => {
const status = tasks?.[taskType];
switch (status) {
case 'running':
return { label: 'Running...', color: 'secondary', icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
return { label: 'Running...', color: 'secondary' as const, icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
case 'failed':
return { label: 'Failed', color: 'error', icon: <WarningIcon sx={{ fontSize: 14 }} /> };
return { label: 'Failed', color: 'error' as const, icon: <WarningIcon sx={{ fontSize: 14 }} /> };
case 'pending':
return { label: 'Scheduled', color: 'default', icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
return { label: 'Scheduled', color: 'default' as const, icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
default:
return { label: 'Active', color: 'success', icon: null };
return { label: 'Active', color: 'success' as const, icon: null };
}
};
@@ -49,15 +113,15 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🚀 Data-Driven Content Intelligence (Advertools)
Data-Driven Content Intelligence (Advertools)
</Typography>
<Tooltip title="Deep insights extracted from your actual site content and structure.">
<UpdateIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<Grid container spacing={3}>
{/* Content Themes & Persona Augmentation */}
{/* 1. Content Themes & Persona Augmentation */}
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
@@ -67,35 +131,17 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
Augmented Content Themes
</Typography>
</Box>
<Chip
label={auditStatus.label}
size="small"
color={auditStatus.color as any}
variant="outlined"
icon={auditStatus.icon as any}
sx={{ height: 20, fontSize: '0.65rem' }}
/>
<Chip label={auditStatus.label} size="small" color={auditStatus.color} variant="outlined" icon={auditStatus.icon as any} sx={{ height: 20, fontSize: '0.65rem' }} />
</Box>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 2 }}>
Actual themes discovered from your content crawl. These are used to refine your brand persona.
Actual themes discovered from your content crawl.
</Typography>
{augmented_themes && augmented_themes.length > 0 ? (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
{augmented_themes.slice(0, 15).map((theme: any, idx: number) => (
<Tooltip key={idx} title={`Frequency: ${theme.abs_freq}`}>
<Chip
label={theme.word}
size="small"
sx={{
bgcolor: 'rgba(139, 92, 246, 0.1)',
color: '#a78bfa',
border: '1px solid rgba(139, 92, 246, 0.2)',
'&:hover': { bgcolor: 'rgba(139, 92, 246, 0.2)' }
}}
/>
<Chip label={theme.word} size="small" sx={{ bgcolor: 'rgba(139, 92, 246, 0.1)', color: '#a78bfa', border: '1px solid rgba(139, 92, 246, 0.2)', '&:hover': { bgcolor: 'rgba(139, 92, 246, 0.2)' } }} />
</Tooltip>
))}
</Box>
@@ -103,21 +149,15 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
{avg_word_count && (
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Avg. Content Length
</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>
{avg_word_count} words
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Avg. Content Length</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{avg_word_count} words</Typography>
</Box>
</Grid>
)}
{site_health?.top_pillars && (
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Primary Structure
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Primary Structure</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
/{Object.keys(site_health.top_pillars)[0] || 'root'}
</Typography>
@@ -134,7 +174,6 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
{tasks?.content_audit === 'running' && <LinearProgress sx={{ mt: 1, borderRadius: 1 }} color="secondary" />}
</Box>
)}
{last_audit && (
<Typography variant="caption" sx={{ display: 'block', mt: 2, color: 'rgba(255, 255, 255, 0.4)' }}>
Last updated: {new Date(last_audit).toLocaleDateString()}
@@ -143,72 +182,92 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
</GlassCard>
</Grid>
{/* Site Health & Freshness */}
{/* 2. Site Health & Freshness */}
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HealthIcon sx={{ color: '#10b981' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>
Site Health & Freshness
</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Site Health & Freshness</Typography>
</Box>
<Chip
label={healthStatus.label}
size="small"
color={healthStatus.color as any}
variant="outlined"
icon={healthStatus.icon as any}
sx={{ height: 20, fontSize: '0.65rem' }}
/>
<Chip label={healthStatus.label} size="small" color={healthStatus.color} variant="outlined" icon={healthStatus.icon as any} sx={{ height: 20, fontSize: '0.65rem' }} />
</Box>
{site_health && site_health.total_urls ? (
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Total Pages
</Typography>
<Typography variant="h6" sx={{ color: 'white' }}>
{site_health.total_urls}
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VelocityIcon sx={{ fontSize: 14, color: '#3b82f6' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
Publishing Velocity
<>
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Total Pages</Typography>
<Typography variant="h6" sx={{ color: 'white' }}>{site_health.total_urls}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VelocityIcon sx={{ fontSize: 14, color: '#3b82f6' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>Velocity</Typography>
</Box>
<Typography variant="h6" sx={{ color: 'white' }}>
{site_health.publishing_velocity} <Typography component="span" variant="caption">/ wk</Typography>
</Typography>
</Box>
<Typography variant="h6" sx={{ color: 'white' }}>
{site_health.publishing_velocity} <Typography component="span" variant="caption">/ week</Typography>
</Typography>
</Box>
</Grid>
<Grid item xs={12}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2, border: site_health.stale_content_percentage > 30 ? '1px solid rgba(239, 68, 68, 0.2)' : 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WarningIcon sx={{ fontSize: 14, color: site_health.stale_content_percentage > 30 ? '#ef4444' : '#f59e0b' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
Stale Content (6+ months)
</Typography>
</Box>
<Typography variant="h6" sx={{ color: site_health.stale_content_percentage > 30 ? '#f87171' : 'white' }}>
{site_health.stale_content_count} pages ({site_health.stale_content_percentage}%)
</Typography>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TrendBadge trend={site_health.publishing_trend || freshness?.publishing_trend} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>Trend</Typography>
</Box>
{site_health.stale_content_percentage > 30 && (
<Chip label="High Risk" size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.65rem' }} />
)}
<Typography variant="h6" sx={{ color: 'white', textTransform: 'capitalize' }}>
{site_health.publishing_trend || freshness?.publishing_trend || 'unknown'}
</Typography>
</Box>
</Box>
</Grid>
</Grid>
</Grid>
{/* Freshness Score */}
{(freshness?.freshness_score || site_health?.freshness_score) && (
<Box sx={{ mt: 2 }}>
<ScoreBar value={freshness?.freshness_score ?? site_health?.freshness_score} label="Content Freshness Score" />
</Box>
)}
{/* Stale Content */}
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2, border: (site_health.stale_content_percentage || 0) > 30 ? '1px solid rgba(239, 68, 68, 0.2)' : 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WarningIcon sx={{ fontSize: 14, color: (site_health.stale_content_percentage || 0) > 30 ? '#ef4444' : '#f59e0b' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>Stale Content (6+ months)</Typography>
</Box>
<Typography variant="h6" sx={{ color: (site_health.stale_content_percentage || 0) > 30 ? '#f87171' : 'white' }}>
{site_health.stale_content_count} pages ({site_health.stale_content_percentage}%)
</Typography>
</Box>
{(site_health.stale_content_percentage || 0) > 30 && (
<Chip label="High Risk" size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.65rem' }} />
)}
</Box>
</Box>
{/* Publishing Recency */}
{freshness?.publishing_recency && (
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 1 }}>Publishing Recency</Typography>
<Grid container spacing={1}>
{Object.entries(freshness.publishing_recency).map(([period, count]) => (
<Grid item xs={3} key={period}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="subtitle2" sx={{ color: 'white', fontWeight: 600 }}>{count as number}</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)' }}>{period.replace('last_', '').replace('d', 'd')}</Typography>
</Box>
</Grid>
))}
</Grid>
</Box>
)}
</>
) : (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.5)' }}>
@@ -217,7 +276,6 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
{tasks?.site_health === 'running' && <LinearProgress sx={{ mt: 1, borderRadius: 1 }} color="primary" />}
</Box>
)}
{last_health_check && (
<Typography variant="caption" sx={{ display: 'block', mt: 2, color: 'rgba(255, 255, 255, 0.4)' }}>
Last checked: {new Date(last_health_check).toLocaleDateString()}
@@ -225,7 +283,363 @@ export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data })
)}
</GlassCard>
</Grid>
{/* 3. URL Structure Analysis */}
{url_structure && url_structure.total_urls_analyzed > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<UrlIcon sx={{ color: '#3b82f6' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>URL Structure Analysis</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>URLs Analyzed</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{url_structure.total_urls_analyzed}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Avg Depth</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{url_structure.directory_depth?.average_depth || 0}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Max Depth</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{url_structure.directory_depth?.max_depth || 0}</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>URLs with Parameters</Typography>
<Typography variant="subtitle1" sx={{ color: url_structure.parameter_usage?.percentage_with_params > 20 ? '#f87171' : 'white', fontWeight: 600 }}>
{url_structure.parameter_usage?.percentage_with_params || 0}%
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Subdomains</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{url_structure.subdomains?.unique_count || 0}</Typography>
</Box>
</Grid>
</Grid>
{url_structure.directory_depth?.distribution && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Depth Distribution</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(url_structure.directory_depth.distribution).slice(0, 8).map(([depth, count]) => (
<Tooltip key={depth} title={`Depth ${depth}: ${count} pages`}>
<Chip label={`L${depth}: ${count as number}`} size="small" sx={{ bgcolor: 'rgba(59, 130, 246, 0.1)', color: '#93c5fd', border: '1px solid rgba(59, 130, 246, 0.2)', fontSize: '0.65rem' }} />
</Tooltip>
))}
</Box>
</Box>
)}
</GlassCard>
</Grid>
)}
{/* 4. Link Health */}
{link_health && link_health.total_links_found > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<LinkIcon sx={{ color: '#10b981' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Internal Link Health</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Total Links</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{link_health.total_links_found}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Internal</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{link_health.internal_link_count} ({link_health.internal_link_percentage}%)</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>External</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{link_health.external_link_count}</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Nofollow</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{link_health.nofollow_link_count}</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Avg Links/Page</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{link_health.avg_links_per_page}</Typography>
</Box>
</Grid>
</Grid>
{link_health.top_anchor_words && Object.keys(link_health.top_anchor_words).length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Top Anchor Text</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(link_health.top_anchor_words).slice(0, 10).map(([word, count]) => (
<Chip key={word} label={`${word} (${count})`} size="small" sx={{ bgcolor: 'rgba(16, 185, 129, 0.1)', color: '#6ee7b7', border: '1px solid rgba(16, 185, 129, 0.2)', fontSize: '0.65rem' }} />
))}
</Box>
</Box>
)}
</GlassCard>
</Grid>
)}
{/* 5. Redirect Audit */}
{redirect_audit && redirect_audit.total_redirects > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<RedirectIcon sx={{ color: '#f59e0b' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Redirect Audit</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Total Redirects</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{redirect_audit.total_redirects}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Unique Chains</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{redirect_audit.unique_chains}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Multi-Hop</Typography>
<Typography variant="subtitle1" sx={{ color: redirect_audit.multi_hop_chains > 0 ? '#f87171' : 'white', fontWeight: 600 }}>{redirect_audit.multi_hop_chains}</Typography>
</Box>
</Grid>
</Grid>
{redirect_audit.status_distribution && Object.keys(redirect_audit.status_distribution).length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Status Distribution</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(redirect_audit.status_distribution).map(([code, count]) => (
<Chip key={code} label={`${code}: ${count}`} size="small" sx={{ bgcolor: 'rgba(245, 158, 11, 0.1)', color: '#fcd34d', border: '1px solid rgba(245, 158, 11, 0.2)', fontSize: '0.65rem' }} />
))}
</Box>
</Box>
)}
</GlassCard>
</Grid>
)}
{/* 6. Image SEO */}
{image_seo && image_seo.total_images > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ImageIcon sx={{ color: '#8b5cf6' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Image SEO</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Total Images</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{image_seo.total_images}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Missing Alt</Typography>
<Typography variant="subtitle1" sx={{ color: (image_seo.missing_alt_count || 0) > 0 ? '#f87171' : 'white', fontWeight: 600 }}>{image_seo.missing_alt_count || 0}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Alt Coverage</Typography>
<Typography variant="subtitle1" sx={{ color: (image_seo.alt_coverage_percentage || 0) >= 80 ? '#10b981' : '#f59e0b', fontWeight: 600 }}>
{image_seo.alt_coverage_percentage || 0}%
</Typography>
</Box>
</Grid>
</Grid>
<Box sx={{ mt: 1 }}>
<ScoreBar value={image_seo.alt_coverage_percentage || 0} label="Alt Text Coverage" />
</Box>
</GlassCard>
</Grid>
)}
{/* 7. Robots.txt Compliance */}
{robots_txt && robots_txt.success && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<RobotsIcon sx={{ color: '#6366f1' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Robots.txt Compliance</Typography>
</Box>
<ScoreBar value={robots_txt.compliance_score || 0} label="Compliance Score" />
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Directives</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{robots_txt.total_directives}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Sitemap</Typography>
<Typography variant="subtitle1" sx={{ color: robots_txt.has_sitemap_directive ? '#10b981' : '#f87171', fontWeight: 600 }}>
{robots_txt.has_sitemap_directive ? 'Declared' : 'Missing'}
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Crawl-Delay</Typography>
<Typography variant="subtitle1" sx={{ color: robots_txt.has_crawl_delay ? '#10b981' : 'rgba(255,255,255,0.5)', fontWeight: 600 }}>
{robots_txt.has_crawl_delay ? 'Set' : 'Not set'}
</Typography>
</Box>
</Grid>
</Grid>
{robots_txt.issues && robots_txt.issues.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Issues</Typography>
{robots_txt.issues.map((issue: any, idx: number) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<SeverityChip severity={issue.severity} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>{issue.detail}</Typography>
</Box>
))}
</Box>
)}
{robots_txt.user_agents_found && robots_txt.user_agents_found.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>User Agents</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{robots_txt.user_agents_found.map((ua: string, idx: number) => (
<Chip key={idx} label={ua} size="small" sx={{ bgcolor: 'rgba(99, 102, 241, 0.1)', color: '#a5b4fc', border: '1px solid rgba(99, 102, 241, 0.2)', fontSize: '0.65rem' }} />
))}
</Box>
</Box>
)}
</GlassCard>
</Grid>
)}
{/* 8. Crawl Budget Analysis */}
{crawl_budget && crawl_budget.success && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<BudgetIcon sx={{ color: '#f59e0b' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Crawl Budget Analysis</Typography>
</Box>
<ScoreBar value={crawl_budget.optimization_score || 0} label="Optimization Score" />
<Grid container spacing={2}>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Sitemap URLs</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{crawl_budget.sitemap_total_urls}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Pages Crawled</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>{crawl_budget.pages_crawled}</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>Wasted</Typography>
<Typography variant="subtitle1" sx={{ color: (crawl_budget.waste_percentage || 0) > 20 ? '#f87171' : 'white', fontWeight: 600 }}>
{crawl_budget.waste_percentage || 0}%
</Typography>
</Box>
</Grid>
</Grid>
{crawl_budget.depth_distribution && Object.keys(crawl_budget.depth_distribution).length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Crawl Depth Distribution</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(crawl_budget.depth_distribution).slice(0, 6).map(([depth, count]) => (
<Chip key={depth} label={`Depth ${depth}: ${count}`} size="small" sx={{ bgcolor: 'rgba(245, 158, 11, 0.1)', color: '#fcd34d', border: '1px solid rgba(245, 158, 11, 0.2)', fontSize: '0.65rem' }} />
))}
</Box>
</Box>
)}
{crawl_budget.status_distribution && Object.keys(crawl_budget.status_distribution).length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5 }}>Status Distribution</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(crawl_budget.status_distribution).slice(0, 6).map(([code, count]) => (
<Chip key={code} label={`${code}: ${count}`} size="small" sx={{
bgcolor: code.startsWith('2') ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: code.startsWith('2') ? '#6ee7b7' : '#fca5a5',
border: `1px solid ${code.startsWith('2') ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`,
fontSize: '0.65rem',
}} />
))}
</Box>
</Box>
)}
</GlassCard>
</Grid>
)}
{/* 9. Page Status Overview (from site structure) */}
{page_status && Object.keys(page_status).length > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<CheckIcon sx={{ color: '#10b981' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Page Status Distribution</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Object.entries(page_status).map(([code, count]) => (
<Chip key={code} label={`HTTP ${code}: ${count}`} size="small" sx={{
bgcolor: code.startsWith('2') ? 'rgba(16, 185, 129, 0.1)' : code.startsWith('3') ? 'rgba(59, 130, 246, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: code.startsWith('2') ? '#6ee7b7' : code.startsWith('3') ? '#93c5fd' : '#fca5a5',
border: `1px solid ${code.startsWith('2') ? 'rgba(16, 185, 129, 0.2)' : code.startsWith('3') ? 'rgba(59, 130, 246, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`,
fontSize: '0.7rem',
}} />
))}
</Box>
</GlassCard>
</Grid>
)}
{/* 10. Sitemap URLs (from robots.txt) */}
{robots_txt?.sitemap_urls && robots_txt.sitemap_urls.length > 0 && (
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<UrlIcon sx={{ color: '#6366f1' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>Sitemaps Found</Typography>
</Box>
<Table size="small">
<TableBody>
{robots_txt.sitemap_urls.map((url: string, idx: number) => (
<TableRow key={idx} sx={{ '&:hover': { bgcolor: 'rgba(255,255,255,0.03)' } }}>
<TableCell sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)', py: 0.75 }}>
<Typography variant="caption" sx={{ color: '#a5b4fc', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '0.65rem' }}>
{url}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</GlassCard>
</Grid>
)}
</Grid>
</Box>
);
};
};

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Chip,
IconButton,
Tooltip,
CircularProgress,
Collapse,
} from '@mui/material';
import {
Refresh as RefreshIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
CheckCircle as SuccessIcon,
ErrorOutline as FailedIcon,
Schedule as ScheduleIcon,
PauseCircle as PausedIcon,
WarningAmber as InterventionIcon,
Autorenew as ActiveIcon,
} from '@mui/icons-material';
import { useAuth } from '@clerk/clerk-react';
import { getOnboardingTasks, type OnboardingTask } from '../../api/schedulerDashboard';
import { TerminalPaper, terminalColors } from './terminalTheme';
const statusIcon = (status: string) => {
switch (status) {
case 'active': return <ActiveIcon sx={{ fontSize: 16, color: '#4caf50' }} />;
case 'completed': return <SuccessIcon sx={{ fontSize: 16, color: '#2196f3' }} />;
case 'failed': return <FailedIcon sx={{ fontSize: 16, color: '#f44336' }} />;
case 'needs_intervention': return <InterventionIcon sx={{ fontSize: 16, color: '#ff9800' }} />;
case 'paused': return <PausedIcon sx={{ fontSize: 16, color: '#6b7280' }} />;
default: return <ScheduleIcon sx={{ fontSize: 16, color: '#8b9cf7' }} />;
}
};
const statusChipColor = (status: string) => {
switch (status) {
case 'active': return { bg: 'rgba(76,175,80,0.15)', color: '#4caf50' };
case 'completed': return { bg: 'rgba(33,150,243,0.15)', color: '#2196f3' };
case 'failed': return { bg: 'rgba(244,67,54,0.15)', color: '#f44336' };
case 'needs_intervention': return { bg: 'rgba(255,152,0,0.15)', color: '#ff9800' };
case 'paused': return { bg: 'rgba(107,114,128,0.15)', color: '#6b7280' };
default: return { bg: 'rgba(139,156,247,0.15)', color: '#8b9cf7' };
}
};
const formatRelativeTime = (iso: string | null): string => {
if (!iso) return 'Not scheduled';
try {
const date = new Date(iso);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (Math.abs(diffMs) < 60000) return 'Just now';
const diffMin = Math.floor(Math.abs(diffMs) / 60000);
if (diffMin < 60) return diffMs > 0 ? `In ${diffMin}m` : `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return diffMs > 0 ? `In ${diffHr}h` : `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return diffMs > 0 ? `In ${diffDay}d` : `${diffDay}d ago`;
} catch {
return iso;
}
};
const OnboardingTasks: React.FC<{ compact?: boolean }> = ({ compact = false }) => {
const { userId } = useAuth();
const [tasks, setTasks] = useState<OnboardingTask[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const uid = userId || '';
const fetchTasks = async () => {
if (!uid) return;
setLoading(true);
try {
const data = await getOnboardingTasks(uid);
setTasks(data);
} catch {
setTasks([]);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchTasks(); }, [uid]); // eslint-disable-line react-hooks/exhaustive-deps
const activeCount = tasks.filter(t => t.status === 'active').length;
const failedCount = tasks.filter(t => t.status === 'failed' || t.status === 'needs_intervention').length;
const pausedCount = tasks.filter(t => t.status === 'paused').length;
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} sx={{ color: terminalColors.primary }} />
</Box>
);
}
if (tasks.length === 0) {
return (
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: terminalColors.textSecondary }}>
No scheduled tasks found. Tasks will appear after onboarding completion.
</Typography>
</TerminalPaper>
);
}
return (
<Box>
{!compact && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: terminalColors.primary, fontFamily: 'monospace' }}>
Scheduled Tasks Overview
</Typography>
<Chip label={`${activeCount} active`} size="small" sx={{ height: 20, fontSize: 10, fontWeight: 600, bgcolor: 'rgba(76,175,80,0.15)', color: '#4caf50' }} />
{failedCount > 0 && <Chip label={`${failedCount} need attention`} size="small" sx={{ height: 20, fontSize: 10, fontWeight: 600, bgcolor: 'rgba(244,67,54,0.15)', color: '#f44336' }} />}
{pausedCount > 0 && <Chip label={`${pausedCount} paused`} size="small" sx={{ height: 20, fontSize: 10, fontWeight: 600, bgcolor: 'rgba(107,114,128,0.15)', color: '#6b7280' }} />}
<Box sx={{ flex: 1 }} />
<Tooltip title="Refresh">
<IconButton size="small" onClick={fetchTasks} sx={{ color: terminalColors.textSecondary }}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{tasks.map((task) => {
const chipColors = statusChipColor(task.status);
const isExpanded = expanded === task.task_type;
return (
<Box key={`${task.task_type}_${task.task_id}`}
sx={{
borderRadius: 1,
border: `1px solid ${terminalColors.border}`,
bgcolor: isExpanded ? 'rgba(255,255,255,0.04)' : 'transparent',
'&:hover': { bgcolor: 'rgba(255,255,255,0.03)' },
transition: 'background 0.2s',
}}
>
<Box
onClick={() => setExpanded(isExpanded ? null : task.task_type)}
sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1, cursor: 'pointer', userSelect: 'none' }}
>
{statusIcon(task.status)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'rgba(255,255,255,0.9)', fontSize: 13, lineHeight: 1.3 }}>
{task.label}
</Typography>
{!compact && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', display: 'block', fontSize: 10 }}>
{task.frequency}
</Typography>
)}
</Box>
<Chip
label={task.status_label}
size="small"
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: chipColors.bg, color: chipColors.color }}
/>
{isExpanded ? <ExpandLessIcon sx={{ fontSize: 14, color: terminalColors.textSecondary }} /> : <ExpandMoreIcon sx={{ fontSize: 14, color: terminalColors.textSecondary }} />}
</Box>
<Collapse in={isExpanded}>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block', mb: 0.5, lineHeight: 1.4 }}>
{task.description}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mt: 0.5 }}>
{task.website_url && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.35)' }}>
URL: {task.website_url}
</Typography>
)}
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.35)' }}>
Next: {formatRelativeTime(task.next_execution)}
</Typography>
{task.last_success && (
<Typography variant="caption" sx={{ color: '#4caf50' }}>
Last success: {formatRelativeTime(task.last_success)}
</Typography>
)}
{task.last_failure && (
<Typography variant="caption" sx={{ color: '#f44336' }}>
Last failure: {formatRelativeTime(task.last_failure)}
</Typography>
)}
</Box>
{task.failure_reason && (
<Typography variant="caption" sx={{ color: '#f44336', display: 'block', mt: 0.5 }}>
Error: {task.failure_reason}
</Typography>
)}
{task.consecutive_failures > 0 && (
<Typography variant="caption" sx={{ color: '#ff9800', display: 'block', mt: 0.25 }}>
{task.consecutive_failures} consecutive failure{task.consecutive_failures > 1 ? 's' : ''}
</Typography>
)}
</Box>
</Collapse>
</Box>
);
})}
</Box>
</Box>
);
};
export default OnboardingTasks;

View File

@@ -9,6 +9,7 @@ import { styled } from '@mui/material/styles';
import OAuthTokenStatus from './OAuthTokenStatus';
import WebsiteAnalysisStatus from './WebsiteAnalysisStatus';
import PlatformInsightsStatus from './PlatformInsightsStatus';
import OnboardingTasks from './OnboardingTasks';
import { TerminalPaper, terminalColors } from './terminalTheme';
interface TabPanelProps {
@@ -101,6 +102,11 @@ const TaskMonitoringTabs: React.FC = () => {
id="task-monitoring-tab-2"
aria-controls="task-monitoring-tabpanel-2"
/>
<TerminalTab
label="Scheduled Tasks"
id="task-monitoring-tab-3"
aria-controls="task-monitoring-tabpanel-3"
/>
</Tabs>
</Box>
<TabPanel value={value} index={0}>
@@ -118,6 +124,11 @@ const TaskMonitoringTabs: React.FC = () => {
<PlatformInsightsStatus compact={true} />
</Box>
</TabPanel>
<TabPanel value={value} index={3}>
<Box sx={{ p: 2 }}>
<OnboardingTasks compact={true} />
</Box>
</TabPanel>
</TerminalPaper>
);
};

View File

@@ -1,182 +0,0 @@
import React, { useMemo } from 'react';
import { Box, Paper, Stack, Typography, Button, LinearProgress, Alert, Chip } from '@mui/material';
import PlayArrow from '@mui/icons-material/PlayArrow';
import VideoLibrary from '@mui/icons-material/VideoLibrary';
import CheckCircle from '@mui/icons-material/CheckCircle';
import ErrorOutline from '@mui/icons-material/ErrorOutline';
import { Scene, VideoPlan } from '../../../services/youtubeApi';
import { useVideoRenderQueue, SceneVideoJob } from '../hooks/useVideoRenderQueue';
interface VideoRenderQueueProps {
scenes: Scene[];
videoPlan: VideoPlan | null;
resolution: '480p' | '720p' | '1080p';
onSceneVideoReady: (sceneNumber: number, videoUrl: string) => void;
onFinalVideoReady?: (videoUrl: string) => void;
}
const statusColor = (job?: SceneVideoJob) => {
if (!job) return 'default';
if (job.status === 'completed') return 'success';
if (job.status === 'failed') return 'error';
if (job.status === 'running') return 'info';
return 'default';
};
export const VideoRenderQueue: React.FC<VideoRenderQueueProps> = ({
scenes,
videoPlan,
resolution,
onSceneVideoReady,
onFinalVideoReady,
}) => {
const {
jobs,
runSceneVideo,
combineVideos,
combineStatus,
combineProgress,
} = useVideoRenderQueue({
scenes,
videoPlan,
resolution,
onSceneVideoReady,
onCombineReady: onFinalVideoReady,
});
const allVideosReady = useMemo(() => {
const enabled = scenes.filter((s) => s.enabled !== false);
if (enabled.length === 0) return false;
return enabled.every((s) => jobs[s.scene_number]?.videoUrl);
}, [jobs, scenes]);
return (
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
Scene-wise Video Generation
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Generate videos per scene to save costs and retry only failing scenes. Once all scene videos are ready, combine them into a final video.
</Typography>
<Stack spacing={2}>
{scenes.map((scene) => {
const job = jobs[scene.scene_number];
return (
<Paper key={scene.scene_number} variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} flexWrap="wrap">
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Scene {scene.scene_number}: {scene.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{scene.imageUrl ? '✅ Image ready' : '⚠️ Image missing'} · {scene.audioUrl ? '✅ Audio ready' : '⚠️ Audio missing'}
</Typography>
{job?.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{job.error}
</Alert>
)}
</Box>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Chip
label={job?.status ?? 'idle'}
color={statusColor(job) as any}
size="small"
variant="outlined"
/>
<Button
variant="contained"
size="small"
startIcon={<PlayArrow />}
disabled={job?.status === 'running'}
onClick={() => runSceneVideo(scene, { generateAudio: false }).catch(() => {})}
>
{job?.status === 'running'
? 'Generating...'
: job?.status === 'completed'
? 'Regenerate Video'
: 'Generate Video'}
</Button>
{job?.videoUrl && (
<Button
variant="outlined"
size="small"
href={job.videoUrl}
target="_blank"
rel="noreferrer"
>
Preview
</Button>
)}
</Stack>
</Stack>
{job?.status === 'running' && (
<Box sx={{ mt: 1.5 }}>
<LinearProgress variant="determinate" value={job.progress || 0} sx={{ height: 6, borderRadius: 2 }} />
<Typography variant="caption" color="text.secondary">
{Math.round(job.progress || 0)}%
</Typography>
</Box>
)}
</Paper>
);
})}
</Stack>
<Box sx={{ mt: 3, p: 2, border: '1px solid #e5e7eb', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
Final Video
</Typography>
{!allVideosReady && (
<Alert severity="info" icon={<VideoLibrary />}>
Generate videos for all enabled scenes to combine them into a single final video.
</Alert>
)}
{allVideosReady && (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
All scene videos are ready. Combine into a final video.
</Typography>
{combineStatus === 'running' && (
<Box>
<LinearProgress
variant="determinate"
value={combineProgress || 0}
sx={{ height: 6, borderRadius: 2, mb: 0.5 }}
/>
<Typography variant="caption" color="text.secondary">
{Math.round(combineProgress || 0)}%
</Typography>
</Box>
)}
<Stack direction="row" spacing={1} alignItems="center">
<Button
variant="contained"
color="secondary"
startIcon={<VideoLibrary />}
disabled={combineStatus === 'running'}
onClick={() =>
combineVideos(
scenes
.filter((s) => s.enabled !== false)
.map((s) => jobs[s.scene_number]?.videoUrl)
.filter(Boolean) as string[],
videoPlan?.video_summary
).catch(() => {})
}
>
{combineStatus === 'running' ? 'Combining...' : 'Combine Scenes'}
</Button>
{combineStatus === 'completed' && <Chip icon={<CheckCircle />} color="success" label="Final video ready" />}
{combineStatus === 'failed' && (
<Chip icon={<ErrorOutline />} color="error" label="Combine failed, retry" />
)}
</Stack>
</Stack>
)}
</Box>
</Paper>
);
};

View File

@@ -1,279 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { youtubeApi, Scene, VideoPlan, TaskStatus } from '../../../services/youtubeApi';
export type VideoJobStatus = 'idle' | 'running' | 'completed' | 'failed';
export interface SceneVideoJob {
scene_number: number;
status: VideoJobStatus;
progress: number;
taskId?: string;
videoUrl?: string;
error?: string;
}
interface UseVideoRenderQueueOptions {
scenes: Scene[];
videoPlan: VideoPlan | null;
resolution: '480p' | '720p' | '1080p';
onSceneVideoReady?: (sceneNumber: number, videoUrl: string) => void;
onCombineReady?: (videoUrl: string) => void;
}
export const useVideoRenderQueue = ({
scenes,
videoPlan,
resolution,
onSceneVideoReady,
onCombineReady,
}: UseVideoRenderQueueOptions) => {
const [jobs, setJobs] = useState<Record<number, SceneVideoJob>>({});
const [combineTaskId, setCombineTaskId] = useState<string | null>(null);
const [combineProgress, setCombineProgress] = useState<number>(0);
const [combineStatus, setCombineStatus] = useState<VideoJobStatus>('idle');
const pollingRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Initialize jobs for current scenes
useEffect(() => {
setJobs((prev) => {
const next = { ...prev };
scenes.forEach((scene) => {
const sn = scene.scene_number;
if (!next[sn]) {
next[sn] = {
scene_number: sn,
status: scene.videoUrl ? 'completed' : 'idle',
progress: scene.videoUrl ? 100 : 0,
videoUrl: scene.videoUrl,
};
} else if (scene.videoUrl && next[sn].videoUrl !== scene.videoUrl) {
next[sn] = { ...next[sn], videoUrl: scene.videoUrl, status: 'completed', progress: 100 };
}
});
return next;
});
}, [scenes]);
const stopPolling = useCallback((taskId: string) => {
const timer = pollingRef.current.get(taskId);
if (timer) {
clearInterval(timer);
pollingRef.current.delete(taskId);
}
}, []);
const pollTask = useCallback(
(taskId: string, sceneNumber?: number, isCombine?: boolean) => {
const timer = setInterval(async () => {
try {
const status: TaskStatus | null = await youtubeApi.getRenderStatus(taskId);
// Handle null response (task not found) - matches podcast pattern
if (!status) {
console.debug(`[VideoRenderQueue] Task ${taskId} not found, stopping poll`);
stopPolling(taskId);
if (sceneNumber !== undefined) {
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber }),
status: 'failed',
progress: 0,
error: 'Task expired or not found. Please try again.',
},
}));
} else {
setCombineStatus('failed');
}
return; // Don't process further for null responses
}
const progress = status.progress ?? 0;
if (isCombine) {
setCombineProgress(progress);
} else if (sceneNumber !== undefined) {
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber, status: 'running', progress }),
status: status.status === 'failed' ? 'failed' : status.status === 'completed' ? 'completed' : 'running',
progress,
},
}));
}
if (status.status === 'completed') {
stopPolling(taskId);
const result = status.result || {};
if (isCombine) {
const finalUrl = result.final_video_url || result.video_url;
if (finalUrl && onCombineReady) {
onCombineReady(finalUrl);
}
setCombineStatus('completed');
} else if (sceneNumber !== undefined) {
const videoUrl =
result.final_video_url ||
result.video_url ||
(Array.isArray(result.scene_results) && result.scene_results[0]?.video_url);
if (videoUrl && onSceneVideoReady) {
onSceneVideoReady(sceneNumber, videoUrl);
}
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber }),
status: 'completed',
progress: 100,
videoUrl,
},
}));
}
} else if (status.status === 'failed') {
stopPolling(taskId);
const errorMsg = status.error || status.message || 'Video render failed';
if (isCombine) {
setCombineStatus('failed');
} else if (sceneNumber !== undefined) {
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber }),
status: 'failed',
progress: 0,
error: errorMsg,
},
}));
}
}
} catch (err: any) {
// Check if this is a 404 (task not found) - stop polling silently
const isNotFound = err?.response?.status === 404 || err?.status === 404 ||
err?.message?.toLowerCase().includes('not found') ||
err?.response?.data?.error === 'Task not found';
if (isNotFound) {
// Task not found (expired/cleaned up) - stop polling silently
console.debug(`[VideoRenderQueue] Task ${taskId} not found, stopping poll`);
stopPolling(taskId);
if (sceneNumber !== undefined) {
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber }),
status: 'failed',
progress: 0,
error: 'Task expired or not found. Please try again.',
},
}));
} else {
setCombineStatus('failed');
}
return; // Don't process further for expected 404s
}
// Other errors - handle normally
stopPolling(taskId);
if (sceneNumber !== undefined) {
setJobs((prev) => ({
...prev,
[sceneNumber]: {
...(prev[sceneNumber] || { scene_number: sceneNumber }),
status: 'failed',
progress: 0,
error: err instanceof Error ? err.message : 'Video render failed',
},
}));
} else {
setCombineStatus('failed');
}
}
}, 3000);
pollingRef.current.set(taskId, timer);
},
[onCombineReady, onSceneVideoReady, stopPolling]
);
const runSceneVideo = useCallback(
async (scene: Scene, opts?: { generateAudio?: boolean }) => {
if (!videoPlan) {
throw new Error('Video plan is missing');
}
if (!scene.imageUrl) throw new Error('Scene image is required before video generation.');
if (!scene.audioUrl && !opts?.generateAudio) throw new Error('Scene audio is required before video generation.');
const sn = scene.scene_number;
setJobs((prev) => ({
...prev,
[sn]: { scene_number: sn, status: 'running', progress: 5 },
}));
const resp = await youtubeApi.generateSceneVideo({
scene,
video_plan: videoPlan,
resolution,
generate_audio_enabled: Boolean(opts?.generateAudio),
});
if (resp.success && resp.task_id) {
setJobs((prev) => ({
...prev,
[sn]: { ...(prev[sn] || { scene_number: sn }), status: 'running', taskId: resp.task_id, progress: 5 },
}));
pollTask(resp.task_id, sn, false);
} else {
setJobs((prev) => ({
...prev,
[sn]: { scene_number: sn, status: 'failed', progress: 0, error: resp.message },
}));
throw new Error(resp.message || 'Failed to start scene video render');
}
},
[videoPlan, resolution, pollTask]
);
const combineVideos = useCallback(
async (videoUrls: string[], title?: string) => {
if (!videoUrls || videoUrls.length < 2) {
throw new Error('At least two scene videos are required to combine.');
}
setCombineStatus('running');
setCombineProgress(5);
const resp = await youtubeApi.combineVideos({
scene_video_urls: videoUrls,
resolution,
title,
});
if (resp.success && resp.task_id) {
setCombineTaskId(resp.task_id);
setCombineProgress(10);
pollTask(resp.task_id, undefined, true);
} else {
setCombineStatus('failed');
throw new Error(resp.message || 'Failed to start combine task');
}
},
[pollTask, resolution]
);
// Cleanup polling on unmount
useEffect(() => {
return () => {
pollingRef.current.forEach((timer) => clearInterval(timer));
pollingRef.current.clear();
};
}, []);
return {
jobs,
runSceneVideo,
combineVideos,
combineTaskId,
combineProgress,
combineStatus,
};
};

View File

@@ -424,6 +424,87 @@ export const useBlogWriterState = () => {
// For now, just log the content
}, []);
// Restore full blog state from a loaded BlogAssetFull object
const restoreFromAsset = useCallback((asset: any) => {
if (!asset) return;
try {
// Restore research
if (asset.research_data) {
setResearch(asset.research_data);
localStorage.setItem('blog_research_cache', JSON.stringify(asset.research_data));
}
// Restore outline
if (asset.outline_data) {
const od = asset.outline_data;
if (od.outline && Array.isArray(od.outline)) {
setOutline(od.outline);
localStorage.setItem('blog_outline', JSON.stringify(od.outline));
}
if (od.selected_title) {
setSelectedTitle(od.selected_title);
localStorage.setItem('blog_selected_title', od.selected_title);
}
if (od.title_options && Array.isArray(od.title_options)) {
setTitleOptions(od.title_options);
localStorage.setItem('blog_title_options', JSON.stringify(od.title_options));
}
setOutlineConfirmed(true);
localStorage.setItem('blog_outline_confirmed', 'true');
}
// Restore content sections
if (asset.content_data && typeof asset.content_data === 'object') {
const sectionsMap: Record<string, string> = {};
Object.entries(asset.content_data).forEach(([key, value]) => {
if (typeof value === 'string') {
sectionsMap[key] = value;
}
});
if (Object.keys(sectionsMap).length > 0) {
setSections(sectionsMap);
setContentConfirmed(true);
localStorage.setItem('blog_content_confirmed', 'true');
// Also write to the blog writer cache
try {
const cacheKey = 'blogwriter_content_' + JSON.stringify(Object.keys(sectionsMap));
localStorage.setItem(cacheKey, JSON.stringify(sectionsMap));
} catch {}
}
}
// Restore SEO
if (asset.seo_data) {
const sd = asset.seo_data;
if (sd.analysis) {
setSeoAnalysis(sd.analysis);
localStorage.setItem('blog_seo_analysis', JSON.stringify(sd.analysis));
}
if (sd.metadata) {
setSeoMetadata(sd.metadata);
localStorage.setItem('blog_seo_metadata', JSON.stringify(sd.metadata));
}
if (sd.recommendations_applied) {
localStorage.setItem('blog_seo_recommendations_applied', 'true');
}
}
// Restore publish completion
if (asset.publish_data) {
localStorage.setItem('blog_publish_completed', 'true');
}
// Restore phase
const phase = asset.phase || 'research';
localStorage.setItem('blogwriter_current_phase', phase);
localStorage.setItem('blogwriter_user_selected_phase', 'true');
console.log('[BlogWriterState] Restored from asset:', asset.id, 'phase:', phase);
} catch (e) {
console.error('[BlogWriterState] Failed to restore from asset:', e);
}
}, []);
return {
// State
research,
@@ -483,6 +564,9 @@ export const useBlogWriterState = () => {
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
handleContentSave,
// Asset restoration
restoreFromAsset
};
};

View File

@@ -93,6 +93,7 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
await new Promise<void>((resolve) => {
let resolved = false;
let completionSource = '';
const finish = (connected: boolean) => {
if (resolved) return;
@@ -103,11 +104,13 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
clearInterval(connectionCheckInterval);
try { popup.close(); } catch { /* COOP may block close across origins */ }
if (connected) {
console.log(`[GSC] Connection resolved via ${completionSource || 'unknown'}`);
checkConnection().then(() => {
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
resolve();
});
} else {
console.warn(`[GSC] Connection failed via ${completionSource || 'unknown'}`);
setConnectError('Google Search Console connection was cancelled or failed.');
resolve();
}
@@ -120,8 +123,10 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
const { type } = event.data as { type?: string };
if (type === 'GSC_AUTH_SUCCESS') {
completionSource = 'postMessage:success';
finish(true);
} else if (type === 'GSC_AUTH_ERROR') {
completionSource = 'postMessage:error';
finish(false);
}
};
@@ -133,6 +138,7 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
if (resolved) return;
try {
if (popup.closed) {
completionSource = 'popup.closed';
// Popup closed — check if connection succeeded
checkConnection().then((connected) => {
if (connected) {
@@ -153,23 +159,26 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
}, 500);
// 3. Poll backend connection status (works even when postMessage is blocked)
// Checks every 2s after a 1s initial delay to let the OAuth flow complete
let checkCount = 0;
const connectionCheckInterval = setInterval(() => {
if (resolved) return;
checkCount++;
if (checkCount < 2) return; // Skip first 2 checks (1s) to let OAuth start
if (checkCount < 2) return;
checkConnection().then((connected) => {
if (connected) finish(true);
if (connected) {
completionSource = 'backend-poll';
finish(true);
}
});
}, 1500);
// 4. Safety timeout
const safetyTimeout = setTimeout(() => {
if (!resolved) {
completionSource = 'timeout';
checkConnection().then((connected) => finish(connected));
}
}, 2 * 60 * 1000); // 2 min safety timeout (reduced from 3)
}, 2 * 60 * 1000);
});
} catch (error) {
console.error('GSC OAuth error:', error);

View File

@@ -330,7 +330,7 @@ export const youtubeApi = {
async combineVideos(params: CombineVideosRequest): Promise<{ success: boolean; task_id?: string; message: string }> {
try {
const response = await apiClient.post(`${API_BASE}/render/combine`, {
video_urls: params.scene_video_urls,
scene_video_urls: params.scene_video_urls,
video_plan: params.video_plan,
resolution: params.resolution || '720p',
title: params.title,