Added image generation to blog writer
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { debug } from '../../utils/debug';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../services/blogWriterApi';
|
||||
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
@@ -26,10 +29,16 @@ import OutlineRefiner from './OutlineRefiner';
|
||||
import { SEOProcessor } from './SEO';
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
import { OutlineProgressModal } from './OutlineProgressModal';
|
||||
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
||||
import { BlogEditor } from './WYSIWYG';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
|
||||
import HeaderBar from './BlogWriterUtils/HeaderBar';
|
||||
import PhaseContent from './BlogWriterUtils/PhaseContent';
|
||||
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
|
||||
|
||||
// Type assertion for CopilotKit action
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -59,6 +68,7 @@ export const BlogWriter: React.FC = () => {
|
||||
flowAnalysisResults,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
@@ -79,6 +89,227 @@ export const BlogWriter: React.FC = () => {
|
||||
handleContentSave
|
||||
} = useBlogWriterState();
|
||||
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
|
||||
const lastSEOModalOpenRef = useRef<number>(0);
|
||||
|
||||
// Phase navigation hook
|
||||
const {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
resetUserSelection
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
);
|
||||
|
||||
// Helper: run same checks as analyzeSEO and open modal
|
||||
const runSEOAnalysisDirect = (): string => {
|
||||
const hasSections = !!sections && Object.keys(sections).length > 0;
|
||||
const hasResearch = !!research && !!(research as any).keyword_analysis;
|
||||
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
|
||||
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
|
||||
// Prevent rapid re-opens
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
|
||||
return "SEO analysis is already open.";
|
||||
}
|
||||
|
||||
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
|
||||
if (!contentConfirmed) {
|
||||
setContentConfirmed(true);
|
||||
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
|
||||
}
|
||||
|
||||
setSeoRecommendationsApplied(false);
|
||||
if (!isSEOAnalysisModalOpen) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
lastSEOModalOpenRef.current = now;
|
||||
debug.log('[BlogWriter] SEO modal opened (direct)');
|
||||
}
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
};
|
||||
|
||||
const handleApplySeoRecommendations = useCallback(async (
|
||||
recommendations: BlogSEOActionableRecommendation[]
|
||||
) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
const sectionPayload = outline.map((section) => ({
|
||||
id: section.id,
|
||||
heading: section.heading,
|
||||
content: sections[section.id] ?? '',
|
||||
}));
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
sections: sectionPayload,
|
||||
outline,
|
||||
research: (research as any) || {},
|
||||
recommendations,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to apply recommendations.');
|
||||
}
|
||||
|
||||
if (!response.sections || !Array.isArray(response.sections)) {
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
// Update sections - create new object reference to trigger React re-render
|
||||
const newSections: Record<string, string> = {};
|
||||
response.sections.forEach((section) => {
|
||||
if (section.id && section.content) {
|
||||
newSections[section.id] = section.content;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate we have sections before updating
|
||||
if (Object.keys(newSections).length === 0) {
|
||||
throw new Error('No valid sections received from SEO recommendations application.');
|
||||
}
|
||||
|
||||
// Validate sections have actual content
|
||||
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
|
||||
if (sectionsWithContent.length === 0) {
|
||||
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
|
||||
}
|
||||
|
||||
// Log detailed section info for debugging
|
||||
const sectionIds = Object.keys(newSections);
|
||||
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
|
||||
sectionCount: sectionIds.length,
|
||||
sectionsWithContent: sectionsWithContent.length,
|
||||
sectionIds: sectionIds,
|
||||
sectionSizes: sectionSizes,
|
||||
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
// Update sections state
|
||||
setSections(newSections);
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
|
||||
if (response.title && response.title !== selectedTitle) {
|
||||
setSelectedTitle(response.title);
|
||||
}
|
||||
|
||||
if (response.applied) {
|
||||
setSeoAnalysis(prev => prev ? { ...prev, applied_recommendations: response.applied } : prev);
|
||||
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
|
||||
}
|
||||
|
||||
// Mark recommendations as applied (this will trigger phase navigation check)
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
setSeoAnalysis(analysis);
|
||||
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
|
||||
}, [setSeoAnalysis]);
|
||||
|
||||
// Handle SEO modal close - mark SEO phase as done if not already marked
|
||||
const handleSEOModalClose = useCallback(() => {
|
||||
// Mark SEO phase as done when modal closes (even without applying recommendations)
|
||||
if (!seoAnalysis) {
|
||||
// Set a minimal valid seoAnalysis object to mark phase as complete
|
||||
setSeoAnalysis({
|
||||
success: true,
|
||||
overall_score: 0,
|
||||
category_scores: {},
|
||||
analysis_summary: {
|
||||
overall_grade: 'N/A',
|
||||
status: 'Skipped',
|
||||
strongest_category: 'N/A',
|
||||
weakest_category: 'N/A',
|
||||
key_strengths: [],
|
||||
key_weaknesses: [],
|
||||
ai_summary: 'SEO analysis was skipped by user'
|
||||
},
|
||||
actionable_recommendations: [],
|
||||
generated_at: new Date().toISOString()
|
||||
});
|
||||
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
|
||||
}
|
||||
setIsSEOAnalysisModalOpen(false);
|
||||
debug.log('[BlogWriter] SEO modal closed');
|
||||
}, [seoAnalysis, setSeoAnalysis, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
// Mark SEO phase as completed when recommendations are applied
|
||||
useEffect(() => {
|
||||
if (seoRecommendationsApplied && seoAnalysis) {
|
||||
// SEO phase is considered complete when recommendations are applied
|
||||
// But stay in SEO phase to show updated content
|
||||
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content (override auto-progression)
|
||||
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
|
||||
}
|
||||
}
|
||||
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
|
||||
|
||||
// Track when outlines/content become available for the first time
|
||||
const prevOutlineLenRef = useRef<number>(outline.length);
|
||||
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
|
||||
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
|
||||
|
||||
useEffect(() => {
|
||||
const prevLen = prevOutlineLenRef.current;
|
||||
if (research && prevLen === 0 && outline.length > 0) {
|
||||
resetUserSelection();
|
||||
}
|
||||
prevOutlineLenRef.current = outline.length;
|
||||
}, [research, outline.length, resetUserSelection]);
|
||||
|
||||
// Only reset user selection when transitioning from not-confirmed to confirmed
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevOutlineConfirmedRef.current;
|
||||
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
|
||||
resetUserSelection(); // Allow auto-progression to content phase
|
||||
}
|
||||
prevOutlineConfirmedRef.current = outlineConfirmed;
|
||||
}, [outlineConfirmed, sections, resetUserSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevContentConfirmedRef.current;
|
||||
if (!wasConfirmed && contentConfirmed && seoAnalysis) {
|
||||
resetUserSelection(); // Allow auto-progression to SEO phase
|
||||
}
|
||||
prevContentConfirmedRef.current = contentConfirmed;
|
||||
}, [contentConfirmed, seoAnalysis, resetUserSelection]);
|
||||
|
||||
// Custom hooks for complex functionality
|
||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||
outline,
|
||||
@@ -139,28 +370,63 @@ export const BlogWriter: React.FC = () => {
|
||||
onError: (err) => console.error('Rewrite failed:', err)
|
||||
});
|
||||
|
||||
// Get context-aware suggestions based on current task status
|
||||
const suggestions = useSuggestions(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
|
||||
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
|
||||
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
|
||||
Object.keys(sections).length > 0, // hasContent
|
||||
flowAnalysisCompleted, // flowAnalysisCompleted state
|
||||
contentConfirmed // contentConfirmed state
|
||||
);
|
||||
|
||||
// Add minimum display time for modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||
|
||||
// SEO Analysis Modal state
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const suggestions = useSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling: { isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
|
||||
outlinePolling: { isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
|
||||
mediumPolling: { isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
|
||||
hasContent: Object.keys(sections).length > 0,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Drive CopilotKit suggestions programmatically
|
||||
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
|
||||
const setSuggestionsRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
|
||||
}, [copilotHeadless]);
|
||||
|
||||
const suggestionsPayload = React.useMemo(
|
||||
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
|
||||
[suggestions]
|
||||
);
|
||||
const prevSuggestionsRef = useRef<string>("__init__");
|
||||
const suggestionsJson = React.useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!setSuggestionsRef.current) return;
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch {}
|
||||
}, [suggestionsJson, suggestionsPayload]);
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
navigateToPhase(phaseId);
|
||||
if (phaseId === 'seo') {
|
||||
if (seoAnalysis) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
|
||||
} else {
|
||||
runSEOAnalysisDirect();
|
||||
}
|
||||
}
|
||||
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect]);
|
||||
const outlineGenRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
@@ -214,96 +480,73 @@ export const BlogWriter: React.FC = () => {
|
||||
progressCount: mediumPolling.progressMessages.length
|
||||
});
|
||||
|
||||
// Debug SEO modal state
|
||||
console.log('🔍 SEO Analysis Modal state:', {
|
||||
isSEOAnalysisModalOpen,
|
||||
hasResearch: !!research,
|
||||
hasContent: !!sections && Object.keys(sections).length > 0,
|
||||
researchKeys: research ? Object.keys(research) : [],
|
||||
sectionsKeys: sections ? Object.keys(sections) : []
|
||||
});
|
||||
// Log critical state changes only (reduce noise)
|
||||
const lastPhaseRef = useRef<string>('');
|
||||
const lastSeoOpenRef = useRef<boolean>(false);
|
||||
const lastSectionsLenRef = useRef<number>(0);
|
||||
|
||||
// Debug action registration
|
||||
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
|
||||
|
||||
// Copilot action for confirming blog content
|
||||
useCopilotActionTyped({
|
||||
name: "confirmBlogContent",
|
||||
description: "Confirm that the blog content is ready and move to the next stage (SEO analysis)",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
return "Blog content has been confirmed! You can now proceed with SEO analysis and publishing.";
|
||||
useEffect(() => {
|
||||
if (currentPhase !== lastPhaseRef.current) {
|
||||
debug.log('[BlogWriter] Phase changed', { currentPhase });
|
||||
lastPhaseRef.current = currentPhase;
|
||||
}
|
||||
});
|
||||
}, [currentPhase]);
|
||||
|
||||
// Copilot action for running SEO analysis
|
||||
useCopilotActionTyped({
|
||||
name: "analyzeSEO",
|
||||
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('🚀 SEO Analysis Action Triggered!');
|
||||
console.log('Current modal state before:', isSEOAnalysisModalOpen);
|
||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
||||
|
||||
// Check if we have content to analyze
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
console.log('❌ No content available for SEO analysis');
|
||||
return "No blog content available for SEO analysis. Please generate content first.";
|
||||
useEffect(() => {
|
||||
const open = isSEOAnalysisModalOpen;
|
||||
if (open !== lastSeoOpenRef.current) {
|
||||
debug.log('[BlogWriter] SEO modal', { isOpen: open });
|
||||
lastSeoOpenRef.current = open;
|
||||
}
|
||||
}, [isSEOAnalysisModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const len = Object.keys(sections || {}).length;
|
||||
if (len !== lastSectionsLenRef.current) {
|
||||
debug.log('[BlogWriter] Sections updated', { count: len });
|
||||
lastSectionsLenRef.current = len;
|
||||
}
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
debug.log('[BlogWriter] Suggestions updated', { suggestions });
|
||||
}, [suggestions]);
|
||||
|
||||
// Force-sync Copilot suggestions right after SEO recommendations applied (guarded by previous suggestions key)
|
||||
useEffect(() => {
|
||||
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
|
||||
try {
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
|
||||
// Check if we have research data
|
||||
if (!research || !research.keyword_analysis) {
|
||||
console.log('❌ No research data available for SEO analysis');
|
||||
return "Research data is required for SEO analysis. Please run research first.";
|
||||
}
|
||||
|
||||
// Open SEO analysis modal
|
||||
console.log('✅ All checks passed, opening SEO analysis modal');
|
||||
} catch (e) {
|
||||
console.error('Failed to push Copilot suggestions after SEO apply:', e);
|
||||
}
|
||||
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
|
||||
|
||||
const confirmBlogContentCb = useCallback(() => {
|
||||
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
resetUserSelection();
|
||||
setSeoRecommendationsApplied(false);
|
||||
navigateToPhase('seo');
|
||||
setTimeout(() => {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
console.log('Modal state set to true');
|
||||
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}
|
||||
});
|
||||
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
|
||||
}, 0);
|
||||
return "✅ Blog content has been confirmed! Running SEO analysis now.";
|
||||
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
// Generate SEO Metadata Action
|
||||
useCopilotActionTyped({
|
||||
name: "generateSEOMetadata",
|
||||
description: "Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data",
|
||||
parameters: [
|
||||
{
|
||||
name: "title",
|
||||
type: "string",
|
||||
description: "Optional blog title to use for metadata generation",
|
||||
required: false
|
||||
}
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
console.log('🚀 Generate SEO Metadata Action Triggered!');
|
||||
console.log('Title provided:', title);
|
||||
console.log('Selected title:', selectedTitle);
|
||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
||||
|
||||
// Check if we have content to generate metadata for
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return "Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.";
|
||||
}
|
||||
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return "Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.";
|
||||
}
|
||||
|
||||
// Open the SEO metadata modal
|
||||
setIsSEOMetadataModalOpen(true);
|
||||
console.log('SEO Metadata modal opened');
|
||||
|
||||
return "Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.";
|
||||
}
|
||||
useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent: confirmBlogContentCb,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -366,6 +609,7 @@ export const BlogWriter: React.FC = () => {
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
ref={outlineGenRef}
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
@@ -395,241 +639,70 @@ export const BlogWriter: React.FC = () => {
|
||||
{!research ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// This will trigger the copilot to start the research process
|
||||
// The user can then interact with the copilot to begin research
|
||||
// Trigger the copilot to start the research process
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{outlineConfirmed ? (
|
||||
/* WYSIWYG Editor - Show when outline is confirmed */
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
/* Outline Editor - Show when outline is not confirmed */
|
||||
<>
|
||||
{/* Enhanced Title Selection */}
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
/>
|
||||
|
||||
|
||||
{/* Enhanced Outline Editor */}
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
|
||||
{/* Draft/Polished Mode Toggle */}
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label style={{ marginRight: 8 }}>Generation mode:</label>
|
||||
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
|
||||
<option value="draft">Draft (faster, lower cost)</option>
|
||||
<option value="polished">Polished (higher quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{outline.map(s => (
|
||||
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h4 style={{ margin: 0 }}>{s.heading}</h4>
|
||||
{/* Continuity badge */}
|
||||
{sections[s.id] && (
|
||||
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
|
||||
)}
|
||||
</div>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
<PhaseContent
|
||||
currentPhase={currentPhase}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
setOutline={setOutline}
|
||||
sections={sections}
|
||||
handleContentUpdate={handleContentUpdate}
|
||||
handleContentSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
outlineGenRef={outlineGenRef}
|
||||
blogWriterApi={blogWriterApi}
|
||||
contentConfirmed={contentConfirmed}
|
||||
seoAnalysis={seoAnalysis}
|
||||
seoMetadata={seoMetadata}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.'
|
||||
}}
|
||||
<WriterCopilotSidebar
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
// Get current state information
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch ? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
||||
} : null;
|
||||
|
||||
const outlineContext = hasOutline ? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${outline.map(s => s.heading).join(', ')}
|
||||
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
|
||||
- Section breakdown: ${outline.map(s => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`).join('; ')}
|
||||
` : '';
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
/>
|
||||
|
||||
{/* Outline Progress Modal */}
|
||||
{/* Outline modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error}
|
||||
/>
|
||||
|
||||
{/* Medium generation / Rewrite modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ?
|
||||
(rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : '') :
|
||||
(mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : '')
|
||||
}
|
||||
error={rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
<TaskProgressModals
|
||||
showOutlineModal={showOutlineModal}
|
||||
outlinePolling={outlinePolling}
|
||||
showModal={showModal}
|
||||
rewritePolling={rewritePolling}
|
||||
mediumPolling={mediumPolling}
|
||||
/>
|
||||
|
||||
{/* SEO Analysis Modal */}
|
||||
<SEOAnalysisModal
|
||||
isOpen={isSEOAnalysisModalOpen}
|
||||
onClose={() => setIsSEOAnalysisModalOpen(false)}
|
||||
onClose={handleSEOModalClose}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
onApplyRecommendations={(recommendations) => {
|
||||
console.log('Applying SEO recommendations:', recommendations);
|
||||
// TODO: Implement recommendation application logic
|
||||
}}
|
||||
onApplyRecommendations={handleApplySeoRecommendations}
|
||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
@@ -639,10 +712,14 @@ Available tools:
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
outline={outline}
|
||||
seoAnalysis={seoAnalysis}
|
||||
onMetadataGenerated={(metadata) => {
|
||||
console.log('SEO metadata generated:', metadata);
|
||||
setSeoMetadata(metadata);
|
||||
// TODO: Implement metadata application logic
|
||||
// Metadata is now saved and will be used when publishing to WordPress/Wix
|
||||
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
|
||||
// Publisher component will use this metadata when calling publish API
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
|
||||
|
||||
interface BlogWriterLandingProps {
|
||||
onStartWriting: () => void;
|
||||
@@ -198,7 +199,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Modal */}
|
||||
{/* SuperPowers Modal with 6 Phases */}
|
||||
{showSuperPowers && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
@@ -206,20 +207,18 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: '1400px',
|
||||
minHeight: '100%',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{/* 6 Phases Section */}
|
||||
<BlogWriterPhasesSection />
|
||||
|
||||
{/* Quick SuperPowers Grid */}
|
||||
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '1.5rem',
|
||||
textAlign: 'center',
|
||||
color: '#333'
|
||||
}}>
|
||||
Quick Feature Overview
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.1rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 style={{
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '1.3rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
|
||||
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
|
||||
|
||||
interface PhaseFeature {
|
||||
title: string;
|
||||
description: string;
|
||||
details: string[];
|
||||
imagePlaceholder: string;
|
||||
}
|
||||
|
||||
interface BlogPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
shortDescription: string;
|
||||
features: PhaseFeature[];
|
||||
technicalDetails: {
|
||||
aiModel: string;
|
||||
promptType: string;
|
||||
outputFormat: string;
|
||||
integration: string;
|
||||
};
|
||||
videoPlaceholder: string;
|
||||
}
|
||||
|
||||
const BlogWriterPhasesSection: React.FC = () => {
|
||||
const [activePhase, setActivePhase] = useState<number | null>(null);
|
||||
|
||||
const phases: BlogPhase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research & Strategy',
|
||||
icon: '🔍',
|
||||
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
|
||||
features: [
|
||||
{
|
||||
title: 'Google Search Grounding',
|
||||
description: 'Real-time web research using Gemini\'s native Google Search integration',
|
||||
details: [
|
||||
'Single API call for comprehensive research',
|
||||
'Live web data from credible sources',
|
||||
'Automatic source extraction and citation',
|
||||
'Current trends and 2024-2025 insights',
|
||||
'Market analysis and forecasts'
|
||||
],
|
||||
imagePlaceholder: '/images/research-google-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Competitor Analysis',
|
||||
description: 'Identify top players and content opportunities in your niche',
|
||||
details: [
|
||||
'Top competitor content analysis',
|
||||
'Content gap identification',
|
||||
'Unique angle discovery',
|
||||
'Market positioning insights',
|
||||
'Competitive advantage opportunities'
|
||||
],
|
||||
imagePlaceholder: '/images/research-competitor.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Intelligence',
|
||||
description: 'Comprehensive keyword analysis with SEO opportunities',
|
||||
details: [
|
||||
'Primary, secondary, and long-tail keyword identification',
|
||||
'Search volume and competition analysis',
|
||||
'Keyword clustering and grouping',
|
||||
'Content optimization suggestions',
|
||||
'Target audience keyword mapping'
|
||||
],
|
||||
imagePlaceholder: '/images/research-keywords.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Angle Generation',
|
||||
description: 'AI-generated compelling content angles for maximum engagement',
|
||||
details: [
|
||||
'5 unique content angle suggestions',
|
||||
'Trending topic identification',
|
||||
'Audience pain point mapping',
|
||||
'Viral potential assessment',
|
||||
'Expert opinion synthesis'
|
||||
],
|
||||
imagePlaceholder: '/images/research-angles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro with Google Search Grounding',
|
||||
promptType: 'Comprehensive research prompt',
|
||||
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
|
||||
integration: 'GeminiGroundedProvider via research_service.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase1-research.mp4'
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Intelligent Outline',
|
||||
icon: '📝',
|
||||
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
|
||||
features: [
|
||||
{
|
||||
title: 'AI Outline Generation',
|
||||
description: 'Comprehensive outline based on research with SEO optimization',
|
||||
details: [
|
||||
'Section-by-section breakdown',
|
||||
'Subheadings and key points',
|
||||
'Target word counts per section',
|
||||
'Logical flow and progression',
|
||||
'SEO-optimized structure'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Mapping & Grounding',
|
||||
description: 'Connect each section to research sources with citations',
|
||||
details: [
|
||||
'Automatic source-to-section mapping',
|
||||
'Grounding support scores',
|
||||
'Citation suggestions',
|
||||
'Source credibility ratings',
|
||||
'Reference verification'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Interactive Refinement',
|
||||
description: 'Human-in-the-loop editing with AI assistance',
|
||||
details: [
|
||||
'Add, remove, merge sections',
|
||||
'Reorder and restructure',
|
||||
'AI enhancement suggestions',
|
||||
'Custom instructions support',
|
||||
'Multiple outline versions'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-refine.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Title Generation',
|
||||
description: 'Multiple SEO-optimized title options',
|
||||
details: [
|
||||
'AI-generated title variations',
|
||||
'SEO score per title',
|
||||
'Engagement potential analysis',
|
||||
'Keyword integration',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-titles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
|
||||
promptType: 'Structured outline prompt with research context',
|
||||
outputFormat: 'JSON outline with sections, headings, key_points, references',
|
||||
integration: 'OutlineService via parallel_processor.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase2-outline.mp4'
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content Generation',
|
||||
icon: '✨',
|
||||
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
|
||||
features: [
|
||||
{
|
||||
title: 'Smart Content Generation',
|
||||
description: 'AI-powered section writing with context awareness',
|
||||
details: [
|
||||
'Section-by-section generation',
|
||||
'Context memory across sections',
|
||||
'Smooth transitions between sections',
|
||||
'Consistent tone and style',
|
||||
'Natural keyword integration'
|
||||
],
|
||||
imagePlaceholder: '/images/content-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Continuity Analysis',
|
||||
description: 'Real-time flow and coherence monitoring',
|
||||
details: [
|
||||
'Narrative flow assessment',
|
||||
'Coherence scoring',
|
||||
'Transition quality analysis',
|
||||
'Tone consistency tracking',
|
||||
'Content quality metrics'
|
||||
],
|
||||
imagePlaceholder: '/images/content-continuity.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Integration',
|
||||
description: 'Automatic citation and source reference',
|
||||
details: [
|
||||
'Relevant URL selection',
|
||||
'Natural citation insertion',
|
||||
'Source attribution',
|
||||
'Evidence-backed content',
|
||||
'Reference management'
|
||||
],
|
||||
imagePlaceholder: '/images/content-sources.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Medium Blog Mode',
|
||||
description: 'Quick generation for Medium-style articles',
|
||||
details: [
|
||||
'Single-call full blog generation',
|
||||
'Medium-optimized formatting',
|
||||
'Engagement-focused structure',
|
||||
'SEO-ready output',
|
||||
'Fast turnaround option'
|
||||
],
|
||||
imagePlaceholder: '/images/content-medium.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
|
||||
promptType: 'Context-aware section prompt with research',
|
||||
outputFormat: 'Markdown content with transitions and metrics',
|
||||
integration: 'EnhancedContentGenerator with ContextMemory'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase3-content.mp4'
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO Analysis',
|
||||
icon: '📈',
|
||||
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive SEO Scoring',
|
||||
description: 'Multi-dimensional SEO analysis across key factors',
|
||||
details: [
|
||||
'Overall SEO score (0-100)',
|
||||
'Structure optimization score',
|
||||
'Keyword optimization rating',
|
||||
'Readability assessment',
|
||||
'Quality metrics evaluation'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-scoring.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Actionable Recommendations',
|
||||
description: 'AI-powered improvement suggestions',
|
||||
details: [
|
||||
'Priority-ranked fixes',
|
||||
'Specific text improvements',
|
||||
'Keyword density optimization',
|
||||
'Heading structure suggestions',
|
||||
'Content enhancement ideas'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-recommendations.jpg'
|
||||
},
|
||||
{
|
||||
title: 'AI-Powered Content Refinement',
|
||||
description: 'Automatically apply SEO recommendations',
|
||||
details: [
|
||||
'Smart content rewriting',
|
||||
'Preserves original intent',
|
||||
'Natural keyword integration',
|
||||
'Readability improvement',
|
||||
'Structure optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-apply.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Analysis',
|
||||
description: 'Deep dive into keyword performance',
|
||||
details: [
|
||||
'Primary keyword density',
|
||||
'Semantic keyword usage',
|
||||
'Long-tail keyword opportunities',
|
||||
'Keyword distribution heatmap',
|
||||
'Optimization recommendations'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-keywords.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Parallel non-AI analyzers + single AI call',
|
||||
promptType: 'Structured SEO analysis prompt',
|
||||
outputFormat: 'Comprehensive SEO report with scores and recommendations',
|
||||
integration: 'BlogContentSEOAnalyzer with parallel processing'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase4-seo.mp4'
|
||||
},
|
||||
{
|
||||
id: 'metadata',
|
||||
name: 'SEO Metadata',
|
||||
icon: '🎯',
|
||||
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive Metadata',
|
||||
description: 'All-in-one SEO metadata generation',
|
||||
details: [
|
||||
'SEO-optimized title (50-60 chars)',
|
||||
'Meta description with CTA',
|
||||
'URL slug optimization',
|
||||
'Blog tags and categories',
|
||||
'Social hashtags'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-comprehensive.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Open Graph & Twitter Cards',
|
||||
description: 'Rich social media previews',
|
||||
details: [
|
||||
'OG title and description',
|
||||
'Twitter card optimization',
|
||||
'Image preview settings',
|
||||
'Social engagement boost',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-social.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Structured Data',
|
||||
description: 'Schema.org markup for rich snippets',
|
||||
details: [
|
||||
'Article schema',
|
||||
'Organization markup',
|
||||
'Breadcrumb schema',
|
||||
'FAQ schema support',
|
||||
'Enhanced search results'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-schema.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Multi-Format Output',
|
||||
description: 'Ready-to-use metadata in all formats',
|
||||
details: [
|
||||
'HTML meta tags',
|
||||
'JSON-LD structured data',
|
||||
'WordPress export format',
|
||||
'Wix integration format',
|
||||
'One-click copy options'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-export.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
|
||||
promptType: 'Personalized metadata prompt with context',
|
||||
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
|
||||
integration: 'BlogSEOMetadataGenerator with optimization'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase5-metadata.mp4'
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish & Distribute',
|
||||
icon: '🚀',
|
||||
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
|
||||
features: [
|
||||
{
|
||||
title: 'Multi-Platform Publishing',
|
||||
description: 'Publish to multiple platforms simultaneously',
|
||||
details: [
|
||||
'WordPress direct publishing',
|
||||
'Wix blog integration',
|
||||
'Medium publishing',
|
||||
'Custom blog platforms',
|
||||
'API integrations'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-platforms.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Scheduling',
|
||||
description: 'Schedule posts for optimal timing',
|
||||
details: [
|
||||
'Time-based scheduling',
|
||||
'Timezone management',
|
||||
'Bulk scheduling support',
|
||||
'Calendar integration',
|
||||
'Reminder notifications'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-schedule.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Revision Management',
|
||||
description: 'Track and manage content versions',
|
||||
details: [
|
||||
'Version history',
|
||||
'Change tracking',
|
||||
'Rollback capabilities',
|
||||
'A/B testing support',
|
||||
'Performance comparison'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-versions.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Analytics Integration',
|
||||
description: 'Post-publish performance tracking',
|
||||
details: [
|
||||
'View count tracking',
|
||||
'Engagement metrics',
|
||||
'SEO performance',
|
||||
'Traffic analysis',
|
||||
'Conversion tracking'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-analytics.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Platform-specific API integrations',
|
||||
promptType: 'N/A - publishing only',
|
||||
outputFormat: 'Published content with URL',
|
||||
integration: 'Platform APIs via Publisher component'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase6-publish.mp4'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
|
||||
<Container maxWidth="lg">
|
||||
{/* Section Title */}
|
||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
Complete AI Blog Writing Workflow
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
|
||||
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Cards */}
|
||||
<Grid container spacing={4}>
|
||||
{phases.map((phase, index) => (
|
||||
<Grid item xs={12} md={6} key={phase.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: activePhase === index ? 2 : 1,
|
||||
borderColor: activePhase === index ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: 6,
|
||||
}
|
||||
}}
|
||||
onClick={() => setActivePhase(activePhase === index ? null : index)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
|
||||
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
|
||||
{phase.icon}
|
||||
</Typography>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h5" fontWeight={600} gutterBottom>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{phase.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`Phase ${index + 1}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{activePhase === index && (
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* Video Placeholder */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
🎥 Video: {phase.videoPlaceholder}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features Grid */}
|
||||
<Grid container spacing={2} mb={3}>
|
||||
{phase.features.map((feature, idx) => (
|
||||
<Grid item xs={12} sm={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '4/3',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
📷 Image
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{feature.details.slice(0, 3).map((detail, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
|
||||
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{detail}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Technical Details */}
|
||||
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesome sx={{ fontSize: 18 }} />
|
||||
Technical Implementation
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Integration</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{phase.technicalDetails.integration}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterPhasesSection;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PhaseNavigation from '../PhaseNavigation';
|
||||
|
||||
interface HeaderBarProps {
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPhaseClick }) => {
|
||||
return (
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666'
|
||||
}}>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<PhaseNavigation
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={onPhaseClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OutlineCtaBannerProps {
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
Next: Create Outline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineCtaBanner;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import ResearchResults from '../ResearchResults';
|
||||
import EnhancedTitleSelector from '../EnhancedTitleSelector';
|
||||
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
|
||||
import { BlogEditor } from '../WYSIWYG';
|
||||
import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
|
||||
interface PhaseContentProps {
|
||||
currentPhase: string;
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
titleOptions: any[];
|
||||
selectedTitle?: string | null;
|
||||
researchTitles: any[];
|
||||
aiGeneratedTitles: any[];
|
||||
sourceMappingStats: any;
|
||||
groundingInsights: any;
|
||||
optimizationResults: any;
|
||||
researchCoverage: any;
|
||||
setOutline: (o: any) => void;
|
||||
sections: Record<string, string>;
|
||||
handleContentUpdate: any;
|
||||
handleContentSave: any;
|
||||
continuityRefresh: number | null;
|
||||
flowAnalysisResults: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
blogWriterApi: any;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
onTitleSelect: any;
|
||||
onCustomTitle: any;
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
currentPhase,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
setOutline,
|
||||
sections,
|
||||
handleContentUpdate,
|
||||
handleContentSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
outlineGenRef,
|
||||
blogWriterApi,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
onTitleSelect,
|
||||
onCustomTitle
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{currentPhase === 'research' && (
|
||||
<>
|
||||
{research ? (
|
||||
<ResearchResults research={research} />
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Start Your Research</h3>
|
||||
<p>Use the copilot to begin researching your blog topic.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'outline' && research && (
|
||||
<>
|
||||
{outline.length === 0 && (
|
||||
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
|
||||
)}
|
||||
{outline.length > 0 ? (
|
||||
<>
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle || undefined}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Create Your Outline</h3>
|
||||
<p>Use the copilot to generate an outline based on your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'content' && outline.length > 0 && (
|
||||
<>
|
||||
{outlineConfirmed ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Confirm Your Outline</h3>
|
||||
<p>Review and confirm your outline before generating content.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
|
||||
<>
|
||||
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Loading Content...</h3>
|
||||
<p>Please wait while your content is being optimized.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback for SEO phase if conditions not met */}
|
||||
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Optimize your blog for search engines.</h3>
|
||||
<p>Complete the content phase first to enable SEO optimization.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Publish Your Blog</h3>
|
||||
<p>Your blog is ready to publish!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseContent;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { OutlineProgressModal } from '../OutlineProgressModal';
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
currentStatus: string;
|
||||
progressMessages: { message: string }[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface TaskProgressModalsProps {
|
||||
showOutlineModal: boolean;
|
||||
outlinePolling: PollingState;
|
||||
showModal: boolean;
|
||||
rewritePolling: PollingState;
|
||||
mediumPolling: PollingState;
|
||||
}
|
||||
|
||||
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
|
||||
showOutlineModal,
|
||||
outlinePolling,
|
||||
showModal,
|
||||
rewritePolling,
|
||||
mediumPolling,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error ?? null}
|
||||
/>
|
||||
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ? (
|
||||
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
|
||||
) : (
|
||||
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
|
||||
)}
|
||||
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgressModals;
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
|
||||
interface WriterCopilotSidebarProps {
|
||||
suggestions: any[];
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
}
|
||||
|
||||
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
suggestions,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch
|
||||
? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational',
|
||||
}
|
||||
: null;
|
||||
|
||||
const outlineContext = hasOutline
|
||||
? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${outline.map((s: any) => s.heading).join(', ')}
|
||||
- Total target words: ${outline.reduce((sum: number, s: any) => sum + (s.target_words || 0), 0)}
|
||||
- Section breakdown: ${outline
|
||||
.map(
|
||||
(s: any) => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`
|
||||
)
|
||||
.join('; ')}
|
||||
`
|
||||
: '';
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriterCopilotSidebar;
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
type ConfirmCb = () => string | Promise<string>;
|
||||
type AnalyzeCb = () => string | Promise<string>;
|
||||
type OpenMetadataCb = () => void;
|
||||
|
||||
interface UseBlogWriterCopilotActionsParams {
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
lastSEOModalOpenRef: React.MutableRefObject<number>;
|
||||
runSEOAnalysisDirect: AnalyzeCb;
|
||||
confirmBlogContent: ConfirmCb;
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
export function useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// confirmBlogContent
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmBlogContent',
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// analyzeSEO
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeSEO',
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
hasResearch: !!research && !!(research as any)?.keyword_analysis,
|
||||
});
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
|
||||
return 'SEO analysis is already open.';
|
||||
}
|
||||
const msg = await runSEOAnalysisDirect();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// generateSEOMetadata
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
|
||||
parameters: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Optional blog title to use for metadata generation',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
|
||||
}
|
||||
openSEOMetadata();
|
||||
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useBlogWriterCopilotActions;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
@@ -17,36 +18,27 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disa
|
||||
|
||||
// If we have flow analysis results, use them instead of API call
|
||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
||||
console.log('🔍 [ContinuityBadge] Flow analysis results available:', flowAnalysisResults);
|
||||
console.log('🔍 [ContinuityBadge] Looking for section ID:', sectionId);
|
||||
console.log('🔍 [ContinuityBadge] Available section IDs:', flowAnalysisResults.sections.map((s: any) => s.section_id));
|
||||
|
||||
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||
if (sectionAnalysis) {
|
||||
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
|
||||
if (mounted) {
|
||||
setMetrics({
|
||||
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
|
||||
flow: sectionAnalysis.flow_score,
|
||||
consistency: sectionAnalysis.consistency_score,
|
||||
progression: sectionAnalysis.progression_score
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if no flow analysis results
|
||||
console.log('🔍 [ContinuityBadge] Fetching continuity for section:', sectionId);
|
||||
debug.log('[ContinuityBadge] fetching', { sectionId });
|
||||
blogWriterApi.getContinuity(sectionId)
|
||||
.then(res => {
|
||||
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
|
||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
|
||||
/* ignore */
|
||||
debug.error('[ContinuityBadge] fetch error', error);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
@@ -24,7 +25,10 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
|
||||
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{imageModalState.open && (
|
||||
<ImageGeneratorModal
|
||||
isOpen={imageModalState.open}
|
||||
onClose={() => setImageModalState({ open: false })}
|
||||
defaultPrompt={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return sec?.heading || '';
|
||||
})()}
|
||||
context={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return {
|
||||
title: sec?.heading,
|
||||
section: sec,
|
||||
outline,
|
||||
research,
|
||||
sectionId: imageModalState.sectionId
|
||||
};
|
||||
})()}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
if (sectionId) {
|
||||
setSectionImages(prev => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
@@ -275,12 +304,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
|
||||
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSection(section.id)}
|
||||
onMouseLeave={() => setHoveredSection(null)}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
@@ -375,6 +407,24 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
title="Generate Image"
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
🖼️ Generate Image
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{expandedSections.has(section.id) && (
|
||||
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Image Display */}
|
||||
{sectionImages[section.id] && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🖼️ Generated Image
|
||||
</h4>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white'
|
||||
}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImages[section.id]}`}
|
||||
alt={`Generated image for ${section.heading}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Generate Image for this section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,269 +12,11 @@ interface KeywordInputFormProps {
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
// Separate component to manage form state
|
||||
const ResearchForm: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { keywords: string; blogLength: string }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [blogLength, setBlogLength] = useState('1000');
|
||||
const hasValidInput = keywords.trim().length > 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({ keywords: keywords.trim(), blogLength });
|
||||
} else {
|
||||
window.alert('Please enter keywords or a topic to start research.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🔍 Let's Research Your Blog Topic
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please provide the keywords or topic you want to research for your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Keywords or Topic *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Blog Length (words)
|
||||
</label>
|
||||
<select
|
||||
value={blogLength}
|
||||
onChange={(e) => setBlogLength(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000+ words (Comprehensive guide)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🚀 Start Research {hasValidInput ? '(Enabled)' : '(Disabled)'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
useCopilotActionTyped({
|
||||
name: 'getResearchKeywords',
|
||||
description: 'Get keywords from user for blog research',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Research keywords received! Starting research...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResearchForm
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
onKeywordsReceived?.(formData);
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Research action that actually performs the research
|
||||
useCopilotActionTyped({
|
||||
name: 'performResearch',
|
||||
description: 'Perform research with collected keywords and blog length',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { keywords, blogLength } = data;
|
||||
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map((k: string) => k.trim())
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
// Store the blog length in localStorage for later use
|
||||
localStorage.setItem('blog_length_target', blogLength);
|
||||
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
onTaskStart?.(task_id); // Notify parent component to start polling
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. Please try again with different keywords.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
|
||||
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
// This component now only provides polling functionality
|
||||
// The keyword input form is handled by ResearchAction component
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -294,4 +36,4 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
export default KeywordInputForm;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
@@ -11,18 +11,38 @@ interface OutlineGeneratorProps {
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow
|
||||
}) => {
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
generateNow: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
try {
|
||||
onModalShow?.();
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
return { success: true, task_id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
}));
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
@@ -64,7 +84,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('generateOutline render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -105,6 +124,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
});
|
||||
|
||||
export default OutlineGenerator;
|
||||
|
||||
89
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
89
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface Phase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
current: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface PhaseNavigationProps {
|
||||
phases: Phase[];
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
currentPhase: string;
|
||||
}
|
||||
|
||||
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
phases,
|
||||
onPhaseClick,
|
||||
currentPhase
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: isCurrent
|
||||
? '#1976d2'
|
||||
: isCompleted
|
||||
? '#4caf50'
|
||||
: isDisabled
|
||||
? '#f5f5f5'
|
||||
: '#e3f2fd',
|
||||
color: isCurrent
|
||||
? 'white'
|
||||
: isCompleted
|
||||
? 'white'
|
||||
: isDisabled
|
||||
? '#999'
|
||||
: '#1976d2',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
|
||||
transform: isCurrent ? 'translateY(-1px)' : 'none'
|
||||
}}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigation;
|
||||
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { Phase } from './PhaseNavigation';
|
||||
|
||||
// Test component to verify phase navigation functionality
|
||||
export const PhaseNavigationTest: React.FC = () => {
|
||||
const [currentPhase, setCurrentPhase] = useState<string>('research');
|
||||
|
||||
const testPhases: Phase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research',
|
||||
icon: '🔍',
|
||||
description: 'Research your topic and gather data',
|
||||
completed: true,
|
||||
current: currentPhase === 'research',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Outline',
|
||||
icon: '📝',
|
||||
description: 'Create and refine your blog outline',
|
||||
completed: true,
|
||||
current: currentPhase === 'outline',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content',
|
||||
icon: '✍️',
|
||||
description: 'Generate and edit your blog content',
|
||||
completed: false,
|
||||
current: currentPhase === 'content',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO',
|
||||
icon: '📈',
|
||||
description: 'Optimize for search engines',
|
||||
completed: false,
|
||||
current: currentPhase === 'seo',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish',
|
||||
icon: '🚀',
|
||||
description: 'Publish your blog post',
|
||||
completed: false,
|
||||
current: currentPhase === 'publish',
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
|
||||
const handlePhaseClick = (phaseId: string) => {
|
||||
setCurrentPhase(phaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h2>Phase Navigation Test</h2>
|
||||
<p>Current Phase: <strong>{currentPhase}</strong></p>
|
||||
|
||||
<PhaseNavigation
|
||||
phases={testPhases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h3>Phase Status:</h3>
|
||||
<ul>
|
||||
{testPhases.map(phase => (
|
||||
<li key={phase.id}>
|
||||
<strong>{phase.name}</strong>:
|
||||
{phase.completed ? ' ✅ Completed' : ' ⏳ Pending'} |
|
||||
{phase.current ? ' 🎯 Current' : ''} |
|
||||
{phase.disabled ? ' 🚫 Disabled' : ' ✅ Enabled'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigationTest;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
@@ -15,13 +15,18 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
const [forceUpdate, setForceUpdate] = useState<number>(0);
|
||||
|
||||
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
setForceUpdate(prev => prev + 1); // Force re-render
|
||||
},
|
||||
onComplete: (result) => {
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
@@ -35,84 +40,170 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'showResearchForm',
|
||||
description: 'Show keyword input form for blog research',
|
||||
parameters: [],
|
||||
handler: async () => ({
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
}),
|
||||
render: ({ status }: any) => {
|
||||
const _ = forceUpdate;
|
||||
|
||||
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
|
||||
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
|
||||
return (
|
||||
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
|
||||
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}>✅ Research completed successfully!</p>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (polling.currentStatus === 'in_progress' || polling.currentStatus === 'running') {
|
||||
return (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
|
||||
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000 words (Comprehensive blog)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const keywords = (keywordsRef.current?.value || '').trim();
|
||||
const blogLength = blogLengthRef.current?.value || '1000';
|
||||
if (!keywords) return;
|
||||
try {
|
||||
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
onResearchComplete?.(cachedResult);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
|
||||
>
|
||||
🚀 Start Research
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Additional action to catch the specific suggestion message
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
parameters: [
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: false },
|
||||
{ name: 'industry', type: 'string', description: 'Industry', required: false },
|
||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
|
||||
],
|
||||
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
|
||||
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
|
||||
try {
|
||||
// If keywords is a topic description, preserve as single phrase unless comma-separated
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim())
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
const industryValue = industry || 'General';
|
||||
const audienceValue = target_audience || 'General';
|
||||
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, industryValue, audienceValue);
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
const trimmed = keywords.trim();
|
||||
if (!trimmed) {
|
||||
return "Please provide keywords or a topic for research.";
|
||||
}
|
||||
|
||||
const keywordList = trimmed.includes(',')
|
||||
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industryValue,
|
||||
target_audience: audienceValue,
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
industry,
|
||||
target_audience,
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
return "Starting research with your provided keywords.";
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
|
||||
};
|
||||
console.error('Failed to start research:', error);
|
||||
return "Failed to start research. Please try again.";
|
||||
}
|
||||
},
|
||||
render: () => null
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
<>
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title={"Research in progress"}
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface ResearchPollingHandlerProps {
|
||||
taskId: string | null;
|
||||
@@ -19,11 +20,11 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
console.log('ResearchPollingHandler - Progress message received:', message);
|
||||
debug.log('[ResearchPollingHandler] progress', { message });
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.log('ResearchPollingHandler - Research completed:', result);
|
||||
debug.log('[ResearchPollingHandler] complete');
|
||||
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
@@ -39,7 +40,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
setCurrentMessage('');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
debug.error('[ResearchPollingHandler] error', error);
|
||||
onError?.(error);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
@@ -61,14 +62,14 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
};
|
||||
}, [polling]);
|
||||
|
||||
console.log('ResearchPollingHandler render:', {
|
||||
taskId,
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressMessages: polling.progressMessages?.length,
|
||||
currentMessage,
|
||||
error: polling.error
|
||||
});
|
||||
// Only log on meaningful changes
|
||||
useEffect(() => {
|
||||
debug.log('[ResearchPollingHandler] state', {
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressCount: polling.progressMessages?.length || 0
|
||||
});
|
||||
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
|
||||
|
||||
// Render the unified research progress modal when a task is present
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Keyword Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive keyword analysis including keyword types, densities,
|
||||
* missing keywords, over-optimization, and distribution analysis.
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
GpsFixed,
|
||||
Search,
|
||||
Warning
|
||||
@@ -36,86 +36,140 @@ interface KeywordAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
const baseCardSx = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const subCard = (color: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${color}`,
|
||||
background: `linear-gradient(145deg, ${color}14, ${color}1f)`
|
||||
});
|
||||
|
||||
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const keywordData = detailedAnalysis?.keyword_analysis;
|
||||
|
||||
const renderDensityRow = (keyword: string, density: number) => {
|
||||
const status = density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal';
|
||||
const chipColor = density > 3 ? 'error' : density < 1 ? 'warning' : 'success';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
|
||||
{keyword}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{status}
|
||||
</Typography>
|
||||
<Chip label={`${density.toFixed(1)}%`} color={chipColor} size="small" sx={{ fontWeight: 600 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GpsFixed sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Keyword Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Keyword Types Overview */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Types Found
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(34,197,94,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
|
||||
Primary Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.primary_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(59,130,246,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
|
||||
Long-tail Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.long_tail_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ fontWeight: 600, borderColor: '#93c5fd', color: '#1d4ed8' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(168,85,247,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
|
||||
Semantic Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.semantic_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" color="secondary" size="small" sx={{ fontWeight: 600, borderColor: '#d8b4fe' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Keyword Densities */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Keyword Densities
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Keyword Density Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Shows how frequently each keyword appears in your content as a percentage of total words.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 1-3% for primary keywords
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Too Low (<1%):</strong> Keyword may not be prominent enough
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalys
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<Search />
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
|
||||
<Box key={keyword} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{keyword}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${density.toFixed(1)}%`}
|
||||
color={density > 3 ? 'error' : density < 1 ? 'warning' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
|
||||
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
|
||||
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No keyword density data available. Make sure your research data includes target keywords.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
||||
{/* Missing Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'error.main' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#dc2626' }}>
|
||||
Missing Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'error.main' }}>
|
||||
<Warning />
|
||||
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
|
||||
<IconButton size="small" sx={{ color: '#dc2626' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
|
||||
{keywordData.missing_keywords.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
|
||||
{/* Over-Optimized Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'warning.main' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#d97706' }}>
|
||||
Over-Optimized Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'warning.main' }}>
|
||||
<Warning />
|
||||
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
|
||||
<IconButton size="small" sx={{ color: '#d97706' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
|
||||
{keywordData.over_optimization.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Keyword Distribution Analysis */}
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Distribution Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
"{keyword}"
|
||||
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
“{keyword}”
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Density: {data.density?.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
First Occurrence: Character {data.first_occurrence || 'Not found'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
Core SEO Metadata
|
||||
</Typography>
|
||||
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* SEO Title */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
SEO Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
value={metadata.seo_title || ''}
|
||||
onChange={handleTextFieldChange('seo_title')}
|
||||
placeholder="Enter SEO-optimized title (50-60 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include your primary keyword and make it compelling for clicks
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include your primary keyword and keep between 50–60 characters
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Meta Description */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Meta Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
value={metadata.meta_description || ''}
|
||||
onChange={handleTextFieldChange('meta_description')}
|
||||
placeholder="Enter compelling meta description (150-160 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include a call-to-action and your primary keyword
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Aim for 150–160 characters with a clear value proposition
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* URL Slug */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinkIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
URL Slug
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
onChange={handleTextFieldChange('url_slug')}
|
||||
placeholder="seo-friendly-url-slug"
|
||||
helperText="Use lowercase letters, numbers, and hyphens only"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Focus Keyword */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Focus Keyword
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
onChange={handleTextFieldChange('focus_keyword')}
|
||||
placeholder="primary-keyword"
|
||||
helperText="Your main SEO keyword for this post"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Tags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TagIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Tags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Tags</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_tags || []}
|
||||
onChange={handleTagsChange('blog_tags')}
|
||||
input={<OutlinedInput label="Tags" />}
|
||||
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Add relevant tags for better categorization and discoverability
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Add 3–6 relevant tags for better categorization and discoverability
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Categories */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CategoryIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Categories
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Categories</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_categories || []}
|
||||
onChange={handleTagsChange('blog_categories')}
|
||||
input={<OutlinedInput label="Categories" />}
|
||||
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Select 2-3 primary categories for your content
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Select 1–3 primary categories for your content
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Hashtags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TagIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Social Hashtags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Hashtags</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.social_hashtags || []}
|
||||
onChange={handleTagsChange('social_hashtags')}
|
||||
input={<OutlinedInput label="Hashtags" />}
|
||||
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include # symbol for social media platforms
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include # symbol (e.g., #multimodalAI). 3–5 hashtags recommended.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Reading Time */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Reading Time
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
||||
}}
|
||||
helperText="Estimated reading time for your content"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
@@ -12,28 +12,35 @@ import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Alert
|
||||
Tabs,
|
||||
Tab,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Code as CodeIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Google as GoogleIcon
|
||||
Google as GoogleIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface PreviewCardProps {
|
||||
metadata: any;
|
||||
blogTitle: string;
|
||||
previewTabValue: string;
|
||||
onPreviewTabChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||
metadata,
|
||||
blogTitle
|
||||
blogTitle,
|
||||
previewTabValue,
|
||||
onPreviewTabChange
|
||||
}) => {
|
||||
const getCurrentDate = () => {
|
||||
return new Date().toLocaleDateString('en-US', {
|
||||
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Title with Tooltip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
Live Preview
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Live Preview
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="This is how your blog post will appear in search results and social media platforms"
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Google Search Results Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GoogleIcon sx={{ color: '#4285F4' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Google Search Results
|
||||
{/* Platform Sub-Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={previewTabValue}
|
||||
onChange={(e, newValue) => onPreviewTabChange(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
minHeight: 48
|
||||
},
|
||||
'& .Mui-selected': {
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<GoogleIcon />}
|
||||
iconPosition="start"
|
||||
label="Google Search Results"
|
||||
value="google"
|
||||
/>
|
||||
<Tab
|
||||
icon={<FacebookIcon />}
|
||||
iconPosition="start"
|
||||
label="Facebook Preview"
|
||||
value="facebook"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TwitterIcon />}
|
||||
iconPosition="start"
|
||||
label="Twitter Preview"
|
||||
value="twitter"
|
||||
/>
|
||||
<Tab
|
||||
icon={<CodeIcon />}
|
||||
iconPosition="start"
|
||||
label="Rich Snippets Preview"
|
||||
value="richsnippets"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Google Search Results Preview */}
|
||||
{previewTabValue === 'google' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GoogleIcon sx={{ color: '#4285F4', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Google Search Results
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Google SERP Preview - Light Theme (matches actual Google) */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* URL - Google Blue */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#202124',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
display: 'block',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
||||
</Typography>
|
||||
<Chip label="SERP Preview" size="small" color="primary" />
|
||||
</Box>
|
||||
|
||||
{/* Title - Google Blue, hover underline */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#1a0dab',
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
{metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description - Google Gray */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
lineHeight: 1.58,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
||||
</Typography>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#70757a',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{getCurrentDate()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.reading_time || 5} min read
|
||||
</Typography>
|
||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.blog_tags.slice(0, 2).join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
{/* Facebook Preview */}
|
||||
{previewTabValue === 'facebook' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1c1e21' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
<Chip label="Open Graph" size="small" sx={{ bgcolor: '#e7f3ff', color: '#1877F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Facebook Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #dadde1',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f2f3f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #dadde1'
|
||||
}}>
|
||||
{metadata.open_graph?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#1a0dab', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
fontSize: '12px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#1a0dab',
|
||||
fontWeight: 400,
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1.3,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '17px',
|
||||
color: '#050505',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.seo_title || blogTitle}
|
||||
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#4d5156', lineHeight: 1.4, mb: 1 }}>
|
||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Twitter Preview */}
|
||||
{previewTabValue === 'twitter' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f1419' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
<Chip label="Twitter Card" size="small" sx={{ bgcolor: '#e1f5fe', color: '#1DA1F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Twitter Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #eff3f4',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f7f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #eff3f4'
|
||||
}}>
|
||||
{metadata.twitter_card?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
fontSize: '13px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{getCurrentDate()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.reading_time || 5} min read
|
||||
</Typography>
|
||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.blog_tags.slice(0, 2).join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
This is how your blog post will appear in Google search results
|
||||
</Alert>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Media Previews */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
<Chip label="Open Graph" size="small" color="primary" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 200,
|
||||
bgcolor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{metadata.open_graph?.image ? 'Image loaded' : 'No image set'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#65676b', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
|
||||
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#65676b', lineHeight: 1.4 }}>
|
||||
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
<Chip label="Twitter Card" size="small" color="info" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 200,
|
||||
bgcolor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{metadata.twitter_card?.image ? 'Image loaded' : 'No image set'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#536471', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
|
||||
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#536471', lineHeight: 1.4 }}>
|
||||
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
|
||||
{/* Twitter handle */}
|
||||
{metadata.twitter_card?.site && (
|
||||
<Typography variant="caption" sx={{ color: '#536471', mt: 1, display: 'block' }}>
|
||||
{metadata.twitter_card.site}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Rich Snippets Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<CodeIcon sx={{ color: '#34A853' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Rich Snippets Preview
|
||||
</Typography>
|
||||
<Chip label="JSON-LD Schema" size="small" color="success" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
{/* Article Schema Preview */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
<Chip label="Article" size="small" color="success" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: '#4d5156', mb: 2 }}>
|
||||
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
color: '#0f1419',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema?.author?.name && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
By {metadata.json_ld_schema.author.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.datePublished && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.reading_time && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.reading_time} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.wordCount && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.json_ld_schema.wordCount} words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
|
||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156', display: 'block', mb: 1 }}>
|
||||
Keywords:
|
||||
{/* Twitter handle */}
|
||||
{metadata.twitter_card?.site && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
mt: 1,
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card.site}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Rich Snippets Preview */}
|
||||
{previewTabValue === 'richsnippets' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<CodeIcon sx={{ color: '#34A853', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Rich Snippets Preview
|
||||
</Typography>
|
||||
<Chip label="JSON-LD Schema" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
{/* Rich Snippets Card */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* Article Schema Preview */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
<Chip label="Article" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
{metadata.json_ld_schema?.author?.name && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
By {metadata.json_ld_schema.author.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
|
||||
<Chip key={index} label={keyword} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Rich snippets help search engines understand your content and may display additional information in search results
|
||||
</Alert>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Metadata Summary */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon />
|
||||
Metadata Summary
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(76, 175, 80, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'success.main' }}>
|
||||
{metadata.optimization_score || 0}%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Optimization Score
|
||||
|
||||
{metadata.json_ld_schema?.datePublished && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.reading_time && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.reading_time} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.wordCount && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.json_ld_schema.wordCount} words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', display: 'block', mb: 1, fontWeight: 500 }}>
|
||||
Keywords:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={keyword}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: '#e0e0e0', color: '#4d5156' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(33, 150, 243, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
|
||||
{metadata.reading_time || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Reading Time (min)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(156, 39, 176, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'secondary.main' }}>
|
||||
{metadata.blog_tags?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Tags
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(255, 152, 0, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'warning.main' }}>
|
||||
{metadata.blog_categories?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Categories
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const openGraph = metadata.open_graph || {};
|
||||
const twitterCard = metadata.twitter_card || {};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<ShareIcon sx={{ color: 'primary.main' }} />
|
||||
Social Media Metadata
|
||||
</Typography>
|
||||
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* Open Graph Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<LinkedInIcon sx={{ color: '#0077B5' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Open Graph Tags
|
||||
</Typography>
|
||||
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
|
||||
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.title || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'title')}
|
||||
placeholder="Open Graph title (60 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.description || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'description')}
|
||||
placeholder="Open Graph description (160 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.image || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'image')}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.url || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'url')}
|
||||
placeholder="https://example.com/blog-post"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Twitter Card Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Card Tags
|
||||
</Typography>
|
||||
<Chip label="Twitter & X" size="small" color="info" />
|
||||
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.title || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'title')}
|
||||
placeholder="Twitter card title (70 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.description || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'description')}
|
||||
placeholder="Twitter card description (200 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.image || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'image')}
|
||||
placeholder="https://example.com/twitter-image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Site Handle
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.site || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'site')}
|
||||
placeholder="@yourwebsite"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Twitter cards provide rich previews when your content is shared on Twitter/X
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Twitter cards provide rich previews when your content is shared on Twitter/X.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Media Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ShareIcon />
|
||||
Social Media Preview
|
||||
</Typography>
|
||||
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
{/* Facebook Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0' }}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{openGraph.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{openGraph.url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{openGraph.description || 'Your meta description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
{/* Twitter Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0' }}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{twitterCard.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{twitterCard.site || '@yourwebsite'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{twitterCard.description || 'Your Twitter description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
}) => {
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
|
||||
// Helpers for counters and consistent input styling
|
||||
const getCharacterCountColor = (current: number, max: number) => {
|
||||
if (current > max) return 'error';
|
||||
if (current > max * 0.9) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getCharacterCountText = (current: number, max: number) => {
|
||||
if (current > max) return `${current}/${max} (Too long)`;
|
||||
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMetadataEdit(field, event.target.value);
|
||||
};
|
||||
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* Article Information */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Article Schema
|
||||
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.headline || ''}
|
||||
onChange={handleSchemaFieldChange('headline')}
|
||||
placeholder="Article headline"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.headline || '').length, 110)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.description || ''}
|
||||
onChange={handleSchemaFieldChange('description')}
|
||||
placeholder="Article description"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.description || '').length, 200)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.description || '').length, 200)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">words</InputAdornment>
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Author Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonIcon />
|
||||
Author Information
|
||||
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={author.name || ''}
|
||||
onChange={handleAuthorFieldChange('name')}
|
||||
placeholder="Author Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={author['@type'] || ''}
|
||||
onChange={handleAuthorFieldChange('@type')}
|
||||
placeholder="Person"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Publisher Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BusinessIcon />
|
||||
Publisher Information
|
||||
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={publisher.name || ''}
|
||||
onChange={handlePublisherFieldChange('name')}
|
||||
placeholder="Publisher Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={publisher.logo || ''}
|
||||
onChange={handlePublisherFieldChange('logo')}
|
||||
placeholder="https://example.com/logo.png"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Publication Dates */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarIcon />
|
||||
Publication Dates
|
||||
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.datePublished || ''}
|
||||
onChange={handleSchemaFieldChange('datePublished')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.dateModified || ''}
|
||||
onChange={handleSchemaFieldChange('dateModified')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Keywords */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Keywords & Categories
|
||||
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
}}
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
helperText="Separate keywords with commas"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
readOnly: true,
|
||||
sx: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem'
|
||||
fontSize: '0.875rem',
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0'
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
|
||||
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* OverallScoreCard Component
|
||||
*
|
||||
* Renders the compact overall SEO score summary with grade chip and
|
||||
* category score tiles.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Chip,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
|
||||
interface MetricTooltip {
|
||||
title: string;
|
||||
description: string;
|
||||
methodology: string;
|
||||
score_meaning: string;
|
||||
examples: string;
|
||||
}
|
||||
|
||||
interface OverallScoreCardProps {
|
||||
overallScore: number;
|
||||
overallGrade: string;
|
||||
statusLabel: string;
|
||||
categoryScores: Record<string, number>;
|
||||
getMetricTooltip: (category: string) => MetricTooltip;
|
||||
getScoreColor: (score: number) => string;
|
||||
}
|
||||
|
||||
const getGradeMeta = (grade: string) => {
|
||||
switch (grade) {
|
||||
case 'A':
|
||||
return {
|
||||
color: '#16a34a',
|
||||
background: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.18))',
|
||||
tooltip: 'Grade A: Outstanding SEO health with only minor optimizations needed.'
|
||||
};
|
||||
case 'B':
|
||||
return {
|
||||
color: '#0ea5e9',
|
||||
background: 'linear-gradient(135deg, rgba(14,165,233,0.12), rgba(2,132,199,0.18))',
|
||||
tooltip: 'Grade B: Strong SEO foundation with several opportunities to optimize further.'
|
||||
};
|
||||
case 'C':
|
||||
return {
|
||||
color: '#d97706',
|
||||
background: 'linear-gradient(135deg, rgba(251,191,36,0.14), rgba(217,119,6,0.2))',
|
||||
tooltip: 'Grade C: Moderate SEO performance. Prioritize improvements in weaker categories.'
|
||||
};
|
||||
case 'D':
|
||||
return {
|
||||
color: '#ea580c',
|
||||
background: 'linear-gradient(135deg, rgba(251,113,133,0.14), rgba(249,115,22,0.2))',
|
||||
tooltip: 'Grade D: Significant SEO gaps detected. Address critical issues promptly.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: '#475569',
|
||||
background: 'linear-gradient(135deg, rgba(148,163,184,0.14), rgba(100,116,139,0.2))',
|
||||
tooltip: 'SEO grade unavailable. Review analysis details for more information.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const OverallScoreCard: React.FC<OverallScoreCardProps> = ({
|
||||
overallScore,
|
||||
overallGrade,
|
||||
statusLabel,
|
||||
categoryScores,
|
||||
getMetricTooltip,
|
||||
getScoreColor
|
||||
}) => {
|
||||
const gradeMeta = getGradeMeta(overallGrade);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
sx={{
|
||||
pb: 0,
|
||||
'& .MuiCardHeader-content': {
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}
|
||||
>
|
||||
Overall SEO Performance Snapshot
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
pt: 2,
|
||||
pb: { xs: 2.5, md: 3 },
|
||||
px: { xs: 2, md: 3 }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
alignItems: { xs: 'stretch', md: 'flex-start' }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: { md: 240 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: { xs: 'flex-start', md: 'center' },
|
||||
gap: 1.5,
|
||||
background: 'linear-gradient(145deg, rgba(241,245,249,0.7), rgba(255,255,255,0.95))',
|
||||
borderRadius: 2,
|
||||
p: { xs: 1.5, md: 2 }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: { xs: 'left', md: 'center' } }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1,
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.4rem', md: '2.8rem' },
|
||||
lineHeight: 1,
|
||||
background: 'linear-gradient(120deg, #22c55e, #4ade80)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{overallScore}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 600 }}
|
||||
>
|
||||
/100
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.5 }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={gradeMeta.tooltip} arrow placement="top">
|
||||
<Chip
|
||||
label={statusLabel}
|
||||
avatar={
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: '#fff',
|
||||
color: gradeMeta.color,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{overallGrade}
|
||||
</Avatar>
|
||||
}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
px: 2.2,
|
||||
py: 0.5,
|
||||
letterSpacing: 0.3,
|
||||
color: gradeMeta.color,
|
||||
background: gradeMeta.background
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, minmax(110px, 1fr))', sm: 'repeat(3, minmax(110px, 1fr))' },
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
{Object.entries(categoryScores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Tooltip
|
||||
key={category}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.75, color: '#475569' }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 8px 18px rgba(15,23,42,0.06)',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, color: getScoreColor(score), mb: 0.35 }}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', textTransform: 'capitalize', fontWeight: 600 }}
|
||||
>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverallScoreCard;
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Readability Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive readability analysis including readability metrics,
|
||||
* content statistics, sentence/paragraph analysis, and target audience information.
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
MenuBook
|
||||
} from '@mui/icons-material';
|
||||
|
||||
@@ -57,109 +57,186 @@ interface ReadabilityAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
const cardStyles = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 30px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const sectionTitleSx = {
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
color: '#0f172a',
|
||||
mb: 2
|
||||
} as const;
|
||||
|
||||
const statRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 0.5
|
||||
} as const;
|
||||
|
||||
const statLabelSx = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValueSx = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const metricRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'help',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 10px 20px rgba(15,23,42,0.08)'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
}) => {
|
||||
const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
|
||||
|
||||
const getMetricDetails = (metric: string, value: number) => {
|
||||
const tooltips: Record<string, { description: string; interpretation: string }> = {
|
||||
flesch_reading_ease: {
|
||||
description: 'Measures how easy text is to read (0-100 scale).',
|
||||
interpretation: value >= 80 ? 'Very Easy' : value >= 60 ? 'Standard' : 'Challenging'
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: 'U.S. grade level required to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
gunning_fog: {
|
||||
description: 'Years of formal education needed for comprehension.',
|
||||
interpretation: value <= 12 ? 'Easy' : value <= 16 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
smog_index: {
|
||||
description: 'Estimates the years of education needed to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
automated_readability: {
|
||||
description: 'Automated readability score based on characters per word.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
coleman_liau: {
|
||||
description: 'Readability based on characters per word and sentence length.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
tooltips[metric] || {
|
||||
description: 'Readability metric',
|
||||
interpretation: 'No interpretation available'
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatRow = (label: React.ReactNode, value: React.ReactNode) => (
|
||||
<Box sx={statRowSx}>
|
||||
<Typography variant="body2" sx={statLabelSx}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={statValueSx}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<MenuBook sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h3"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.3, color: '#0f172a' }}
|
||||
>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Readability Metrics
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Measures how easy your content is to read and understand.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Average Sentence Length:</strong> 15-20 words is optimal
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Sentence Length:</strong> 15-20 words is optimal
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<MenuBook />
|
||||
<MenuBook fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
|
||||
const getReadabilityTooltip = (metric: string, value: number) => {
|
||||
const tooltips = {
|
||||
flesch_reading_ease: {
|
||||
description: "Measures how easy text is to read (0-100 scale)",
|
||||
interpretation: value >= 80 ? "Very Easy" : value >= 60 ? "Standard" : "Difficult"
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: "U.S. grade level needed to understand the text",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
gunning_fog: {
|
||||
description: "Years of formal education needed to understand the text",
|
||||
interpretation: value <= 12 ? "Easy" : value <= 16 ? "Moderate" : "Difficult"
|
||||
},
|
||||
smog_index: {
|
||||
description: "Simple Measure of Gobbledygook - readability formula",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
automated_readability: {
|
||||
description: "Automated Readability Index based on character count",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
coleman_liau: {
|
||||
description: "Readability test based on average sentence length and characters per word",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
}
|
||||
};
|
||||
return tooltips[metric as keyof typeof tooltips] || { description: "Readability metric", interpretation: "N/A" };
|
||||
};
|
||||
|
||||
const tooltip = getReadabilityTooltip(metric, value);
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{Object.keys(readabilityMetrics).length > 0 ? (
|
||||
Object.entries(readabilityMetrics).map(([metric, value]) => {
|
||||
const { description, interpretation } = getMetricDetails(metric, value);
|
||||
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={metric}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
<strong>Interpretation:</strong> {tooltip.interpretation}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
<strong>Interpretation:</strong> {interpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
||||
<Box sx={metricRowSx}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
|
||||
{metric.replace('_', ' ')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{value.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -167,116 +244,72 @@ export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No readability metrics available. This may indicate an issue with the content analysis.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Content Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Word Count</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Unique Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Vocabulary Diversity</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
|
||||
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
|
||||
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
|
||||
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
|
||||
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
|
||||
{renderStatRow(
|
||||
'Vocabulary Diversity',
|
||||
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
|
||||
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Readability Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Sentence & Paragraph Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Sentence Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Paragraph Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Transition Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow(
|
||||
'Average Sentence Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Average Paragraph Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Transition Words Used',
|
||||
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Reading Level</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Content Depth Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Flow Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
|
||||
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
|
||||
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Recommendations Component
|
||||
*
|
||||
*
|
||||
* Displays actionable SEO recommendations with priority indicators,
|
||||
* category tags, and impact descriptions.
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Paper,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
@@ -30,78 +30,107 @@ interface RecommendationsProps {
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
const priorityStyles: Record<string, { color: string; gradient: string }> = {
|
||||
High: { color: '#dc2626', gradient: 'linear-gradient(135deg, rgba(248,113,113,0.12), rgba(239,68,68,0.18))' },
|
||||
Medium: { color: '#d97706', gradient: 'linear-gradient(135deg, rgba(251,191,36,0.12), rgba(217,119,6,0.16))' },
|
||||
Low: { color: '#16a34a', gradient: 'linear-gradient(135deg, rgba(74,222,128,0.12), rgba(22,163,74,0.16))' },
|
||||
default: { color: '#475569', gradient: 'linear-gradient(135deg, rgba(148,163,184,0.1), rgba(100,116,139,0.14))' }
|
||||
};
|
||||
|
||||
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'error.main';
|
||||
case 'Medium': return 'warning.main';
|
||||
case 'Low': return 'success.main';
|
||||
default: return 'text.secondary';
|
||||
}
|
||||
};
|
||||
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
|
||||
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
|
||||
default: return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'High':
|
||||
return <Cancel sx={{ fontSize: 18 }} />;
|
||||
case 'Medium':
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
case 'Low':
|
||||
return <CheckCircle sx={{ fontSize: 18 }} />;
|
||||
default:
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
const getChipColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High':
|
||||
return 'error';
|
||||
case 'Medium':
|
||||
return 'warning';
|
||||
case 'Low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Lightbulb sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Actionable Recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recommendations.map((rec, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{recommendations.map((rec, index) => {
|
||||
const styles = priorityStyles[rec.priority] || priorityStyles.default;
|
||||
return (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: styles.gradient,
|
||||
color: getPriorityColor(rec.priority)
|
||||
}}
|
||||
>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: '#cbd5f5', color: '#475569', fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getChipColor(rec.priority)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ lineHeight: 1.6, color: '#1f2937' }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Structure Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive content structure analysis including structure overview,
|
||||
* content elements detection, and heading structure analysis.
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
BarChart
|
||||
} from '@mui/icons-material';
|
||||
|
||||
@@ -52,127 +52,157 @@ interface StructureAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
const baseCard = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const infoRow = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 0',
|
||||
cursor: 'help'
|
||||
} as const;
|
||||
|
||||
const statLabel = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValue = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const highlightCard = (borderColor: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${borderColor}`,
|
||||
background: `linear-gradient(140deg, ${borderColor}15, ${borderColor}22)`
|
||||
});
|
||||
|
||||
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const structure = detailedAnalysis?.content_structure;
|
||||
const quality = detailedAnalysis?.content_quality;
|
||||
const headings = detailedAnalysis?.heading_structure;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Content Structure Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
{/* Content Structure Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Structure Overview
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sections
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of main content sections (H2 headings) in your blog post.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good sectioning improves readability and helps search engines understand your content structure.
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sections || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Paragraphs
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of paragraphs in your content (excluding headings).
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Appropriate paragraph count indicates good content depth and organization.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_paragraphs || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Total number of sentences in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Sentence count affects readability and content comprehensiveness.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Structure Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Overall score (0-100) for your content's structural organization.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Section count, paragraph count, introduction/conclusion presence
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Well-structured content ranks better and provides better user experience.
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/conclusion presence.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Structure Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.structure_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
|
||||
{/* Content Elements */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Elements
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Introduction Section
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content has a clear introduction that sets context and expectations.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> Introductions help readers understand what they'll learn and improve engagement.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Clear introductions help search engines understand your content's purpose.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content has a clear introduction that sets context and expectations."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Introduction</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
|
||||
<Chip
|
||||
label={structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={structure?.has_introduction ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Conclusion Section
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content has a clear conclusion that summarizes key points.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> Conclusions help readers retain information and provide closure.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Good conclusions can improve time on page and reduce bounce rate.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content ends with a clear conclusion summarizing key points."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={structure?.has_conclusion ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Call to Action
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content includes a clear call to action for readers.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> CTAs guide readers to take desired actions and improve conversion rates.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Strong CTAs can improve user engagement metrics.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content includes a clear call to action for readers."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={structure?.has_call_to_action ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
{/* Content Quality Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Quality Metrics
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Total number of words in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 800-2000 words for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Longer content typically ranks better and provides more value to readers.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Total number of words in your content. Longer content typically ranks better."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.word_count || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.word_count || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Vocabulary Diversity
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Ratio of unique words to total words, indicating content variety.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 0.4-0.7 (40-70% unique words)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Higher diversity indicates richer, more engaging content.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Ratio of unique words to total words, indicating content variety and richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
Vocabulary Diversity
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.vocabulary_diversity !== undefined
|
||||
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Content Depth Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how comprehensive and detailed your content is.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Word count, section depth, information density
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Deeper content provides more value and ranks better in search results.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Score (0-100) indicating how comprehensive and detailed your content is."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
|
||||
Content Depth Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Flow Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how well your content flows from one idea to the next.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Transition words, sentence variety, logical progression
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good flow improves readability and keeps readers engaged.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Score (0-100) indicating how well your content flows from one idea to the next."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(255, 152, 0, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 152, 0, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
|
||||
Flow Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Transition Words
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Number of transition words used to connect ideas and improve flow.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 5-15 transition words for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Transition words improve readability and help readers follow your logic.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Number of transition words used – higher values suggest smoother narrative flow."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(244, 67, 54, 0.1)', borderRadius: 2, border: '1px solid rgba(244, 67, 54, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'error.main', mb: 1 }}>
|
||||
Transition Words
|
||||
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
|
||||
Transition Words Used
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Unique Words
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Number of unique words used in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> More unique words indicate richer vocabulary and better content variety.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Diverse vocabulary can help with semantic SEO and topic coverage.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Average unique words used throughout the article. Indicates lexical richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(0, 150, 136, 0.1)', borderRadius: 2, border: '1px solid rgba(0, 150, 136, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'info.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
|
||||
Unique Words
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
</Grid>
|
||||
|
||||
{/* Heading Structure */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Heading Structure Analysis
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
{headings && (
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Heading Structure
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
H1 Headings
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h1_count}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Heading Hierarchy Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how well your heading structure follows SEO best practices.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> H1 presence, logical hierarchy, keyword usage in headings
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good heading structure helps search engines understand your content and improves readability.
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, cursor: 'help' }}>
|
||||
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Structure Recommendations */}
|
||||
{detailedAnalysis?.content_structure?.recommendations && detailedAnalysis.content_structure.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(255, 193, 7, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 193, 7, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'warning.main' }}>
|
||||
Structure Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.content_structure.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
H2 Headings
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Heading Recommendations */}
|
||||
{detailedAnalysis?.heading_structure?.recommendations && detailedAnalysis.heading_structure.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'primary.main' }}>
|
||||
Heading Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.heading_structure.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h2_count}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Quality Recommendations */}
|
||||
{detailedAnalysis?.content_quality?.recommendations && detailedAnalysis.content_quality.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'success.main' }}>
|
||||
Content Quality Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.content_quality.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0ea5e9', mb: 1 }}>
|
||||
H3 Headings
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h3_count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
Grid,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Avatar,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import {
|
||||
@@ -32,11 +34,11 @@ import {
|
||||
Warning,
|
||||
TrendingUp,
|
||||
Search,
|
||||
BarChart,
|
||||
Refresh,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
||||
import OverallScoreCard from './SEO/OverallScoreCard';
|
||||
|
||||
interface SEOAnalysisResult {
|
||||
overall_score: number;
|
||||
@@ -139,7 +141,27 @@ interface SEOAnalysisModalProps {
|
||||
blogContent: string;
|
||||
blogTitle?: string;
|
||||
researchData: any;
|
||||
onApplyRecommendations?: (recommendations: any[]) => void;
|
||||
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
|
||||
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
|
||||
}
|
||||
|
||||
// Simple content hashing helper (SHA-256)
|
||||
async function hashContent(text: string): Promise<string> {
|
||||
try {
|
||||
const enc = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest('SHA-256', enc);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
// Fallback hash
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return String(h);
|
||||
}
|
||||
}
|
||||
|
||||
function getSeoCacheKey(contentHash: string, title?: string) {
|
||||
return `seo_cache:${contentHash}:${title || ''}`;
|
||||
}
|
||||
|
||||
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
onApplyRecommendations
|
||||
onApplyRecommendations,
|
||||
onAnalysisComplete
|
||||
}) => {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
||||
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('recommendations');
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
// Debug logging
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
|
||||
const runSEOAnalysis = useCallback(async () => {
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulate progress updates (in real implementation, this would be SSE)
|
||||
// Cache check
|
||||
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
const cacheKey = getSeoCacheKey(hash, blogTitle);
|
||||
if (!forceRefresh) {
|
||||
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
setAnalysisResult(parsed as SEOAnalysisResult);
|
||||
setIsAnalyzing(false);
|
||||
// Notify parent that analysis is complete (from cache)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(parsed as SEOAnalysisResult);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulated progress
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
||||
@@ -182,7 +224,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Make API call to analyze blog content
|
||||
// Backend call
|
||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
blog_content: blogContent,
|
||||
blog_title: blogTitle,
|
||||
@@ -191,15 +233,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
|
||||
const result = response.data;
|
||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||
|
||||
// Convert API response to frontend format - fail fast if data is missing
|
||||
if (!result.success) {
|
||||
throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
}
|
||||
|
||||
if (!result.overall_score && result.overall_score !== 0) {
|
||||
throw new Error('Invalid SEO score received from API');
|
||||
}
|
||||
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
|
||||
|
||||
const convertedResult: SEOAnalysisResult = {
|
||||
overall_score: result.overall_score,
|
||||
@@ -256,13 +291,44 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
};
|
||||
|
||||
setAnalysisResult(convertedResult);
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
const key = getSeoCacheKey(h, blogTitle);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(convertedResult));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// Notify parent that analysis is complete (fresh analysis)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(convertedResult);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData]);
|
||||
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
|
||||
|
||||
// Precompute hash when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
})();
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'success.main';
|
||||
@@ -270,13 +336,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
return 'error.main';
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
|
||||
// Tooltip content for each metric
|
||||
const getMetricTooltip = (category: string) => {
|
||||
const tooltips = {
|
||||
@@ -326,12 +385,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -342,14 +395,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
sx: {
|
||||
maxHeight: '90vh',
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backgroundColor: '#f8fafc',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
color: 'text.primary'
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
color: '#0f172a'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
SEO Analysis Results
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
setAnalysisResult(null);
|
||||
runSEOAnalysis(true);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
Comprehensive analysis of your blog content's SEO optimization
|
||||
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
{analysisResult && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Overall Score Section */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Overall SEO Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(analysisResult.overall_score),
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{analysisResult.overall_score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{analysisResult.analysis_summary.overall_grade}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Grade
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Chip
|
||||
label={analysisResult.analysis_summary.status}
|
||||
color={getScoreBadgeVariant(analysisResult.overall_score)}
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Scores */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Category Breakdown
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(analysisResult.category_scores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Grid item xs={6} md={4} key={category}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 2,
|
||||
cursor: 'help',
|
||||
'&:hover': {
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
transform: 'translateY(-2px)',
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(score),
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OverallScoreCard
|
||||
overallScore={analysisResult.overall_score}
|
||||
overallGrade={analysisResult.analysis_summary.overall_grade}
|
||||
statusLabel={analysisResult.analysis_summary.status}
|
||||
categoryScores={analysisResult.category_scores}
|
||||
getMetricTooltip={getMetricTooltip}
|
||||
getScoreColor={getScoreColor}
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis Tabs */}
|
||||
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
@@ -603,43 +545,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
AI-Powered Insights
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Content Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
|
||||
{analysisResult.analysis_summary.ai_summary}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Key Strengths
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{strength}</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Areas for Improvement
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{weakness}</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{applyError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Cancel sx={{ mr: 1 }} />
|
||||
{applyError}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (onApplyRecommendations) {
|
||||
onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
onClick={async () => {
|
||||
if (!onApplyRecommendations) return;
|
||||
setApplyError(null);
|
||||
setIsApplying(true);
|
||||
try {
|
||||
await onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
// Increased delay to ensure sections are fully updated and phase stays in SEO
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 200);
|
||||
} catch (applyErr: any) {
|
||||
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
disabled={!onApplyRecommendations}
|
||||
disabled={!onApplyRecommendations || isApplying}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
'&:hover': {
|
||||
@@ -672,7 +628,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Recommendations
|
||||
{isApplying ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
Applying...
|
||||
</Box>
|
||||
) : (
|
||||
'Apply Recommendations'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Integration with backend metadata generation
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Chip
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
@@ -42,6 +43,7 @@ import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
|
||||
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
|
||||
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
|
||||
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
|
||||
import { subscribeImage } from '../../utils/imageBus';
|
||||
|
||||
interface SEOMetadataModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -49,6 +51,8 @@ interface SEOMetadataModalProps {
|
||||
blogContent: string;
|
||||
blogTitle: string;
|
||||
researchData: any;
|
||||
outline?: any[]; // Add outline structure
|
||||
seoAnalysis?: any; // Add SEO analysis results
|
||||
onMetadataGenerated: (metadata: any) => void;
|
||||
}
|
||||
|
||||
@@ -71,20 +75,55 @@ interface SEOMetadataResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache helper functions (similar to SEOAnalysisModal)
|
||||
async function hashContent(text: string): Promise<string> {
|
||||
try {
|
||||
const enc = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest('SHA-256', enc);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
// Fallback hash
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return String(h);
|
||||
}
|
||||
}
|
||||
|
||||
function getMetadataCacheKey(contentHash: string, title?: string): string {
|
||||
return `seo_metadata_cache:${contentHash}:${title || ''}`;
|
||||
}
|
||||
|
||||
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
outline,
|
||||
seoAnalysis,
|
||||
onMetadataGenerated
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('core');
|
||||
const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first
|
||||
const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms
|
||||
const [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
|
||||
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
// Subscribe to image generation bus to auto-fill OG/Twitter image fields
|
||||
useEffect(() => {
|
||||
const unsub = subscribeImage(({ base64 }: { base64: string }) => {
|
||||
setEditableMetadata(prev => {
|
||||
const next = { ...(prev || metadataResult || {}) } as any;
|
||||
next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` };
|
||||
next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
}, [metadataResult]);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
@@ -96,19 +135,67 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
});
|
||||
}, [isOpen, blogContent, blogTitle, researchData]);
|
||||
|
||||
const generateMetadata = async () => {
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes (but keep result for next time)
|
||||
setError(null);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-generate metadata when modal opens (only once)
|
||||
const hasAutoGeneratedRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
|
||||
hasAutoGeneratedRef.current = true;
|
||||
generateMetadata(false); // Auto-generate from cache or API
|
||||
}
|
||||
if (!isOpen) {
|
||||
hasAutoGeneratedRef.current = false; // Reset when modal closes
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]); // Only trigger when modal opens
|
||||
|
||||
const generateMetadata = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setMetadataResult(null);
|
||||
if (forceRefresh) {
|
||||
setMetadataResult(null);
|
||||
}
|
||||
|
||||
console.log('🚀 Starting SEO metadata generation...');
|
||||
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
|
||||
|
||||
// Calculate content hash for caching
|
||||
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(hash);
|
||||
const cacheKey = getMetadataCacheKey(hash, blogTitle);
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && typeof window !== 'undefined') {
|
||||
const cached = window.localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOMetadataResult;
|
||||
console.log('✅ Using cached SEO metadata');
|
||||
setMetadataResult(parsed);
|
||||
setEditableMetadata(parsed);
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cached metadata:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make API call to generate metadata
|
||||
const response = await apiClient.post('/api/blog/seo/metadata', {
|
||||
content: blogContent,
|
||||
title: blogTitle,
|
||||
research_data: researchData
|
||||
research_data: researchData,
|
||||
outline: outline || null,
|
||||
seo_analysis: seoAnalysis || null
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
@@ -118,6 +205,16 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
throw new Error(result.error || 'Metadata generation failed');
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(result));
|
||||
console.log('💾 SEO metadata cached');
|
||||
} catch (e) {
|
||||
console.warn('Failed to cache metadata:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setMetadataResult(result);
|
||||
setEditableMetadata(result);
|
||||
console.log('📊 Metadata result set:', result);
|
||||
@@ -128,7 +225,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
@@ -159,6 +256,23 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Apply Metadata button click
|
||||
*
|
||||
* This saves the generated/edited metadata to the parent component's state.
|
||||
* The metadata is then used when publishing to platforms:
|
||||
* - WordPress: Requires SEO metadata for proper post creation with SEO fields
|
||||
* - Wix: Currently doesn't require metadata, but could be added in future
|
||||
*
|
||||
* The metadata includes:
|
||||
* - SEO title, meta description, URL slug
|
||||
* - Blog tags, categories, focus keyword
|
||||
* - Open Graph tags (Facebook/LinkedIn)
|
||||
* - Twitter Card tags
|
||||
* - JSON-LD structured data (Schema.org Article)
|
||||
*
|
||||
* All of these will be passed to the platform's API when publishing.
|
||||
*/
|
||||
const handleApplyMetadata = () => {
|
||||
if (editableMetadata) {
|
||||
onMetadataGenerated(editableMetadata);
|
||||
@@ -222,32 +336,26 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{metadataResult && (
|
||||
<Tooltip title="Regenerate SEO metadata">
|
||||
<IconButton
|
||||
onClick={() => generateMetadata(true)}
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
{!metadataResult && !isGenerating && (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Generate Comprehensive SEO Metadata
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={generateMetadata}
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
Generate SEO Metadata
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isGenerating && (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={generateMetadata}
|
||||
onClick={() => generateMetadata(true)}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Try Again
|
||||
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
scrollButtons="auto"
|
||||
sx={{ minHeight: 48 }}
|
||||
>
|
||||
{['core', 'social', 'structured', 'preview'].map((tab) => (
|
||||
{['preview', 'core', 'social', 'structured'].map((tab) => (
|
||||
<Tab
|
||||
key={tab}
|
||||
value={tab}
|
||||
@@ -332,6 +440,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
<PreviewCard
|
||||
metadata={editableMetadata || metadataResult}
|
||||
blogTitle={blogTitle}
|
||||
previewTabValue={previewTabValue}
|
||||
onPreviewTabChange={setPreviewTabValue}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
||||
return (
|
||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||
<div>Score: {analysis.seo_score}</div>
|
||||
{!!analysis.recommendations?.length && (
|
||||
<div>Score: {analysis.overall_score}</div>
|
||||
{!!analysis.analysis_summary && (
|
||||
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
|
||||
Grade {analysis.analysis_summary.overall_grade} · {analysis.analysis_summary.status}
|
||||
</div>
|
||||
)}
|
||||
{!!analysis.actionable_recommendations?.length && (
|
||||
<ul>
|
||||
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
|
||||
{analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
|
||||
<li key={index}>
|
||||
<strong>{rec.category}:</strong> {rec.recommendation}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,35 @@ interface SuggestionsGeneratorProps {
|
||||
contentConfirmed?: boolean;
|
||||
}
|
||||
|
||||
export const useSuggestions = (
|
||||
research: BlogResearchResponse | null,
|
||||
outline: BlogOutlineSection[],
|
||||
outlineConfirmed: boolean = false,
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string },
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string },
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string },
|
||||
hasContent: boolean = false,
|
||||
flowAnalysisCompleted: boolean = false,
|
||||
contentConfirmed: boolean = false
|
||||
) => {
|
||||
interface SuggestionContext {
|
||||
research: BlogResearchResponse | null;
|
||||
outline: BlogOutlineSection[];
|
||||
outlineConfirmed?: boolean;
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string };
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string };
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string };
|
||||
hasContent?: boolean;
|
||||
flowAnalysisCompleted?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
seoAnalysis?: any;
|
||||
seoMetadata?: any;
|
||||
seoRecommendationsApplied?: boolean;
|
||||
}
|
||||
|
||||
export const useSuggestions = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed = false,
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
hasContent = false,
|
||||
flowAnalysisCompleted = false,
|
||||
contentConfirmed = false,
|
||||
seoAnalysis = null,
|
||||
seoMetadata = null,
|
||||
seoRecommendationsApplied = false
|
||||
}: SuggestionContext) => {
|
||||
return useMemo(() => {
|
||||
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
||||
|
||||
@@ -66,14 +84,14 @@ export const useSuggestions = (
|
||||
if (!research) {
|
||||
items.push({
|
||||
title: '🔎 Start Research',
|
||||
message: "I want to research a topic for my blog",
|
||||
message: "showResearchForm",
|
||||
priority: 'high'
|
||||
});
|
||||
} else if (research && outline.length === 0) {
|
||||
// Research completed, guide user to outline creation
|
||||
items.push({
|
||||
title: 'Next: Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results',
|
||||
message: 'Research is complete. Please generate the blog outline now using the existing research data. Use the generateOutline action immediately without asking for additional information.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
@@ -82,13 +100,13 @@ export const useSuggestions = (
|
||||
});
|
||||
items.push({
|
||||
title: '🎨 Create Custom Outline',
|
||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||
message: 'I want to create an outline with my own specific instructions and requirements. Please ask me for my custom requirements.'
|
||||
});
|
||||
} else if (outline.length > 0 && !outlineConfirmed) {
|
||||
// Outline created but not confirmed - focus on outline review and confirmation
|
||||
items.push({
|
||||
title: 'Next: Confirm & Generate Content',
|
||||
message: 'I confirm the outline and am ready to generate content',
|
||||
message: 'The outline is ready. Confirm the current outline and begin content generation now. Call confirmOutlineAndGenerateContent immediately and do not ask for extra confirmation.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
@@ -106,12 +124,6 @@ export const useSuggestions = (
|
||||
} else if (outline.length > 0 && outlineConfirmed) {
|
||||
// Outline confirmed, focus on content generation and optimization
|
||||
if (hasContent && !contentConfirmed) {
|
||||
// User has content but hasn't confirmed it yet - show content review suggestions
|
||||
items.push({
|
||||
title: 'Next: Confirm Blog Content',
|
||||
message: 'I have reviewed and confirmed my blog content is ready for the next stage',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🔄 ReWrite Blog',
|
||||
message: 'I want to rewrite my blog with different approach, tone, or focus'
|
||||
@@ -121,24 +133,78 @@ export const useSuggestions = (
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post'
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||
});
|
||||
} else if (hasContent && contentConfirmed) {
|
||||
// Content confirmed - move to SEO stage
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🧾 Generate SEO Metadata',
|
||||
message: 'Generate SEO metadata and title'
|
||||
});
|
||||
items.push({
|
||||
title: '🚀 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress'
|
||||
});
|
||||
if (!seoAnalysis) {
|
||||
// Prompt to run SEO analysis first
|
||||
items.push({
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'The blog content is confirmed. Execute analyzeSEO immediately to launch the SEO analysis modal without further prompts.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
} else if (seoAnalysis && !seoRecommendationsApplied) {
|
||||
// SEO analysis exists but recommendations not applied yet
|
||||
items.push({
|
||||
title: 'Next: Apply SEO Recommendations',
|
||||
message: 'Open the SEO analysis modal and apply the actionable recommendations right away. Call analyzeSEO to reopen the modal without extra questions.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Run analyzeContentQuality to review narrative flow and get final improvement suggestions before publishing.'
|
||||
});
|
||||
items.push({
|
||||
title: '📈 Review SEO Analysis',
|
||||
message: 'Show me the latest SEO analysis results again by running analyzeSEO.'
|
||||
});
|
||||
} else if (seoAnalysis && seoRecommendationsApplied) {
|
||||
// SEO analysis exists and recommendations applied - show next steps
|
||||
if (!seoMetadata) {
|
||||
items.push({
|
||||
title: 'Next: Generate SEO Metadata',
|
||||
message: 'SEO recommendations are applied. Execute generateSEOMetadata immediately so we can prepare titles, descriptions, and schema without further prompts.',
|
||||
priority: 'high'
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
title: 'Next: Publish',
|
||||
message: 'The blog is SEO-optimized. Use publishToPlatform with your preferred destination (wix|wordpress) right away—no additional confirmation needed.',
|
||||
priority: 'high'
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
|
||||
});
|
||||
items.push({
|
||||
title: 'Publish',
|
||||
message: seoMetadata
|
||||
? 'Publish my blog to your preferred platform using publishToPlatform.'
|
||||
: 'Generate SEO metadata first, then publish your blog.'
|
||||
});
|
||||
|
||||
if (seoMetadata) {
|
||||
items.push({
|
||||
title: '🚀 Publish to Wix',
|
||||
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".'
|
||||
});
|
||||
items.push({
|
||||
title: '🌐 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No content yet, show generation option
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
@@ -146,11 +212,24 @@ export const useSuggestions = (
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling, hasContent, flowAnalysisCompleted, contentConfirmed]);
|
||||
}, [
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
]);
|
||||
};
|
||||
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
|
||||
useSuggestions(research, outline, outlineConfirmed);
|
||||
useSuggestions({ research, outline, outlineConfirmed });
|
||||
return null; // This is just a utility component
|
||||
};
|
||||
|
||||
|
||||
@@ -70,8 +70,21 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
// Handle text replacement in the textarea
|
||||
if (contentRef.current) {
|
||||
const textarea = contentRef.current;
|
||||
const currentContent = textarea.value;
|
||||
const updatedContent = currentContent.replace(originalText, newText);
|
||||
|
||||
// For smart suggestions, newText is already the complete updated content with insertion
|
||||
// For other edits (like text selection improvements), we need to replace originalText with newText
|
||||
let updatedContent: string;
|
||||
|
||||
if (editType === 'smart-suggestion') {
|
||||
// newText already contains the full content with suggestion inserted
|
||||
updatedContent = newText;
|
||||
} else {
|
||||
// For other edits, replace the selected text
|
||||
const currentContent = textarea.value;
|
||||
updatedContent = currentContent.replace(originalText, newText);
|
||||
}
|
||||
|
||||
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
|
||||
setContent(updatedContent);
|
||||
|
||||
// Update parent state
|
||||
@@ -79,14 +92,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
onContentUpdate([{ id, content: updatedContent }]);
|
||||
}
|
||||
|
||||
// Focus back to textarea and set cursor after the replaced text
|
||||
setTimeout(() => {
|
||||
if (contentRef.current) {
|
||||
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
|
||||
contentRef.current.focus();
|
||||
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
}
|
||||
}, 100);
|
||||
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
|
||||
// For other edits, we may need to handle cursor positioning here if needed
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import TextSelectionMenu from './TextSelectionMenu';
|
||||
import useSmartTypingAssist from './SmartTypingAssist';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface BlogTextSelectionHandlerProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -281,12 +282,15 @@ const useBlogTextSelectionHandler = (
|
||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||
allSuggestions={smartTypingAssist.allSuggestions}
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface SmartTypingAssistProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
|
||||
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
||||
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastGeneratedAtRef = useRef<number>(0);
|
||||
|
||||
// Quality improvement tracking
|
||||
const [suggestionStats, setSuggestionStats] = useState({
|
||||
@@ -52,25 +55,25 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Smart typing assist functionality
|
||||
const generateSmartSuggestion = async (currentText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] generateSmartSuggestion called with text length:', currentText.length);
|
||||
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
|
||||
|
||||
if (currentText.length < 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Text too short for suggestion');
|
||||
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
||||
return; // Only suggest after some meaningful content
|
||||
}
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
|
||||
debug.log('[SmartTypingAssist] Starting suggestion generation...');
|
||||
setIsGeneratingSuggestion(true);
|
||||
|
||||
try {
|
||||
// Import the assistive writing API
|
||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Calling assistive writing API...');
|
||||
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
||||
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
|
||||
|
||||
if (response.success && response.suggestions.length > 0) {
|
||||
console.log('🔍 [SmartTypingAssist] Received', response.suggestions.length, 'suggestions from API');
|
||||
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
||||
|
||||
// Store all suggestions
|
||||
setAllSuggestions(response.suggestions);
|
||||
@@ -78,7 +81,7 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Show first suggestion
|
||||
const firstSuggestion = response.suggestions[0];
|
||||
console.log('🔍 [SmartTypingAssist] Showing first suggestion:', firstSuggestion.text.substring(0, 50) + '...');
|
||||
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
||||
|
||||
// Track suggestion shown
|
||||
setSuggestionStats(prev => ({
|
||||
@@ -86,12 +89,30 @@ const useSmartTypingAssist = (
|
||||
totalShown: prev.totalShown + 1
|
||||
}));
|
||||
|
||||
// Get cursor position for suggestion placement
|
||||
// Get viewport-safe position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
|
||||
const y = Math.max(20, rect.bottom + 10);
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
|
||||
|
||||
// Try to position below the editor
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
// If it would be cut off at the bottom, position above instead
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
// If it would be cut off at the top, position in viewport center
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's never cut off
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: firstSuggestion.text,
|
||||
@@ -101,7 +122,7 @@ const useSmartTypingAssist = (
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
|
||||
debug.log('[SmartTypingAssist] No suggestions received from API');
|
||||
// Fallback to generic suggestions if API fails
|
||||
const fallbackSuggestions = [
|
||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||
@@ -116,8 +137,26 @@ const useSmartTypingAssist = (
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
|
||||
|
||||
// Try to position below the editor
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
// If it would be cut off at the bottom, position above instead
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
// If it would be cut off at the top, position in viewport center
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's never cut off
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
@@ -126,7 +165,7 @@ const useSmartTypingAssist = (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔍 [SmartTypingAssist] Failed to generate smart suggestion:', error);
|
||||
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
||||
|
||||
// Fallback to generic suggestions on error
|
||||
const fallbackSuggestions = [
|
||||
@@ -142,8 +181,14 @@ const useSmartTypingAssist = (
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 160;
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 5;
|
||||
if (y > window.innerHeight - maxHeight) {
|
||||
y = window.innerHeight - (maxHeight + 20);
|
||||
x = Math.max(20, window.innerWidth - (maxWidth + 20));
|
||||
}
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
@@ -156,7 +201,7 @@ const useSmartTypingAssist = (
|
||||
};
|
||||
|
||||
const handleTypingChange = (newText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] handleTypingChange called with text length:', newText.length);
|
||||
// Not logging this as it fires on every keystroke - too noisy
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
@@ -168,29 +213,45 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Set new timeout for suggestion generation
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
console.log('🔍 [SmartTypingAssist] Typing timeout triggered, text length:', newText.length, 'hasShownFirstSuggestion:', hasShownFirstSuggestion);
|
||||
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
|
||||
|
||||
// First time suggestion appears automatically
|
||||
if (!hasShownFirstSuggestion && newText.length > 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
|
||||
const cooldownMs = 15000; // 15s cooldown between suggestions
|
||||
const now = Date.now();
|
||||
const sinceLast = now - lastGeneratedAtRef.current;
|
||||
|
||||
// First time suggestion appears automatically with sufficient content
|
||||
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
|
||||
debug.log('[SmartTypingAssist] Generating first suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
setHasShownFirstSuggestion(true);
|
||||
lastGeneratedAtRef.current = now;
|
||||
}
|
||||
// After first time, only suggest after longer pauses or more content
|
||||
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
|
||||
// After first time, show "Continue writing" prompt instead of random suggestions
|
||||
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
|
||||
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
||||
setShowContinueWritingPrompt(true);
|
||||
}
|
||||
// Removed verbose log about skipping prompts as it's too noisy
|
||||
}, 3000); // 3 second pause before suggesting
|
||||
};
|
||||
|
||||
const handleAcceptSuggestion = () => {
|
||||
if (smartSuggestion && onTextReplace && contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
|
||||
const newContent = currentContent + ' ' + smartSuggestion.text;
|
||||
const element = contentRef.current as HTMLTextAreaElement;
|
||||
const currentContent = element.value || '';
|
||||
|
||||
// Get cursor position
|
||||
const cursorPosition = element.selectionStart || currentContent.length;
|
||||
debug.log('[SmartTypingAssist] Cursor position', { cursorPosition, contentLength: currentContent.length });
|
||||
|
||||
// Insert suggestion at cursor position
|
||||
const beforeCursor = currentContent.substring(0, cursorPosition);
|
||||
const afterCursor = currentContent.substring(cursorPosition);
|
||||
const suggestionWithSpace = ' ' + smartSuggestion.text + ' ';
|
||||
const newContent = beforeCursor + suggestionWithSpace + afterCursor;
|
||||
|
||||
// Calculate where cursor should be after insertion (right after the suggestion)
|
||||
const newCursorPosition = cursorPosition + suggestionWithSpace.length;
|
||||
|
||||
// Track suggestion accepted
|
||||
setSuggestionStats(prev => ({
|
||||
@@ -198,14 +259,21 @@ const useSmartTypingAssist = (
|
||||
totalAccepted: prev.totalAccepted + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
|
||||
...suggestionStats,
|
||||
totalAccepted: suggestionStats.totalAccepted + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
|
||||
|
||||
// Use the text replacement callback
|
||||
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
||||
|
||||
// Set cursor position after the inserted text
|
||||
setTimeout(() => {
|
||||
if (contentRef.current) {
|
||||
const el = contentRef.current as HTMLTextAreaElement;
|
||||
el.focus();
|
||||
el.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
debug.log('[SmartTypingAssist] Cursor positioned', { position: newCursorPosition });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setSmartSuggestion(null);
|
||||
}
|
||||
};
|
||||
@@ -217,10 +285,7 @@ const useSmartTypingAssist = (
|
||||
totalRejected: prev.totalRejected + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
|
||||
...suggestionStats,
|
||||
totalRejected: suggestionStats.totalRejected + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
|
||||
|
||||
setSmartSuggestion(null);
|
||||
setAllSuggestions([]);
|
||||
@@ -238,11 +303,8 @@ const useSmartTypingAssist = (
|
||||
totalCycled: prev.totalCycled + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
|
||||
...suggestionStats,
|
||||
totalCycled: suggestionStats.totalCycled + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
|
||||
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
|
||||
|
||||
setSuggestionIndex(nextIndex);
|
||||
setSmartSuggestion(prev => prev ? {
|
||||
@@ -254,6 +316,25 @@ const useSmartTypingAssist = (
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Continue writing" button click
|
||||
const handleRequestSuggestion = async () => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const element = contentRef.current as HTMLTextAreaElement;
|
||||
const currentContent = element.value || '';
|
||||
|
||||
setShowContinueWritingPrompt(false);
|
||||
|
||||
if (currentContent.length > 20) {
|
||||
await generateSmartSuggestion(currentContent);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dismissing the "Continue writing" prompt
|
||||
const handleDismissPrompt = () => {
|
||||
setShowContinueWritingPrompt(false);
|
||||
};
|
||||
|
||||
// Get suggestion statistics for quality improvement
|
||||
const getSuggestionStats = () => {
|
||||
const acceptanceRate = suggestionStats.totalShown > 0
|
||||
@@ -284,10 +365,13 @@ const useSmartTypingAssist = (
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
suggestionStats: getSuggestionStats(),
|
||||
showContinueWritingPrompt,
|
||||
handleTypingChange,
|
||||
handleAcceptSuggestion,
|
||||
handleRejectSuggestion,
|
||||
handleNextSuggestion,
|
||||
handleRequestSuggestion,
|
||||
handleDismissPrompt,
|
||||
getSuggestionStats,
|
||||
generateSmartSuggestion
|
||||
};
|
||||
|
||||
@@ -34,12 +34,15 @@ interface TextSelectionMenuProps {
|
||||
}>;
|
||||
}>;
|
||||
suggestionIndex: number;
|
||||
showContinueWritingPrompt: boolean;
|
||||
onCheckFacts: (text: string) => void;
|
||||
onCloseFactCheckResults: () => void;
|
||||
onQuickEdit: (editType: string, selectedText: string) => void;
|
||||
onAcceptSuggestion: () => void;
|
||||
onRejectSuggestion: () => void;
|
||||
onNextSuggestion: () => void;
|
||||
onRequestSuggestion: () => void;
|
||||
onDismissPrompt: () => void;
|
||||
}
|
||||
|
||||
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
showContinueWritingPrompt,
|
||||
onCheckFacts,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
onNextSuggestion
|
||||
onNextSuggestion,
|
||||
onRequestSuggestion,
|
||||
onDismissPrompt
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10002,
|
||||
maxWidth: '400px',
|
||||
maxWidth: '420px',
|
||||
minWidth: '320px',
|
||||
maxHeight: '350px',
|
||||
overflow: 'auto',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
@@ -540,6 +548,93 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Writing Prompt */}
|
||||
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(59, 130, 246, 0.95)',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
minWidth: '280px',
|
||||
maxWidth: '360px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
✨ AI Writing Assistant
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onRequestSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✍️ Continue Writing
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissPrompt}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
|
||||
Reference in New Issue
Block a user