chore: bulk commit of local changes across blog writer, SEO dashboard, scheduler, docs-site, and frontend
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\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 {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
212
frontend/src/components/SchedulerDashboard/OnboardingTasks.tsx
Normal file
212
frontend/src/components/SchedulerDashboard/OnboardingTasks.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user