= ({ 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'
}}>
{/* Modal Header */}
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC = ({ onStartWriting })
- {/* SuperPowers Grid */}
-
- {superPowers.map((power, index) => (
-
{
- 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';
- }}
- >
-
+ {/* 6 Phases Section */}
+
+
+ {/* Quick SuperPowers Grid */}
+
+
+ Quick Feature Overview
+
+
+ {superPowers.map((power, index) => (
+
{
+ 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';
+ }}
+ >
- {power.icon}
+
+ {power.icon}
+
+
+ {power.title}
+
-
- {power.title}
-
+ {power.description}
+
-
- {power.description}
-
-
- ))}
+ ))}
+
{/* Modal Footer */}
diff --git a/frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx b/frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
new file mode 100644
index 00000000..3a6acf84
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
@@ -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
(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 (
+
+
+ {/* Section Title */}
+
+
+ Complete AI Blog Writing Workflow
+
+
+ Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
+
+
+
+ {/* Phase Cards */}
+
+ {phases.map((phase, index) => (
+
+ setActivePhase(activePhase === index ? null : index)}
+ >
+
+
+
+ {phase.icon}
+
+
+
+ {phase.name}
+
+
+ {phase.shortDescription}
+
+
+
+
+
+ {activePhase === index && (
+
+ {/* Video Placeholder */}
+
+
+ π₯ Video: {phase.videoPlaceholder}
+
+
+
+ {/* Features Grid */}
+
+ {phase.features.map((feature, idx) => (
+
+
+
+
+ π· Image
+
+
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+ {feature.details.slice(0, 3).map((detail, i) => (
+
+
+
+ {detail}
+
+
+ ))}
+
+
+
+ ))}
+
+
+ {/* Technical Details */}
+
+
+
+ Technical Implementation
+
+
+
+ AI Model
+ {phase.technicalDetails.aiModel}
+
+
+ Output Format
+ {phase.technicalDetails.outputFormat}
+
+
+ Prompt Type
+ {phase.technicalDetails.promptType}
+
+
+ Integration
+
+ {phase.technicalDetails.integration}
+
+
+
+
+
+ )}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default BlogWriterPhasesSection;
+
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/HeaderBar.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/HeaderBar.tsx
new file mode 100644
index 00000000..14fbe899
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/HeaderBar.tsx
@@ -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 = ({ phases, currentPhase, onPhaseClick }) => {
+ return (
+
+
+
AI Blog Writer
+
+ A
+
+
+
+
+ );
+};
+
+export default HeaderBar;
+
+
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/OutlineCtaBanner.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/OutlineCtaBanner.tsx
new file mode 100644
index 00000000..c50c7584
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/OutlineCtaBanner.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+interface OutlineCtaBannerProps {
+ onGenerate: () => void;
+}
+
+const OutlineCtaBanner: React.FC = ({ onGenerate }) => {
+ return (
+
+ Next step: generate your outline from research.
+
+ Next: Create Outline
+
+
+ );
+};
+
+export default OutlineCtaBanner;
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/PhaseContent.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/PhaseContent.tsx
new file mode 100644
index 00000000..4247d788
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/PhaseContent.tsx
@@ -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;
+ handleContentUpdate: any;
+ handleContentSave: any;
+ continuityRefresh: number | null;
+ flowAnalysisResults: any;
+ outlineGenRef: React.RefObject;
+ blogWriterApi: any;
+ contentConfirmed: boolean;
+ seoAnalysis: any;
+ seoMetadata: any;
+ onTitleSelect: any;
+ onCustomTitle: any;
+}
+
+export const PhaseContent: React.FC = ({
+ 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 (
+
+
+ {currentPhase === 'research' && (
+ <>
+ {research ? (
+
+ ) : (
+
+
Start Your Research
+
Use the copilot to begin researching your blog topic.
+
+ )}
+ >
+ )}
+
+ {currentPhase === 'outline' && research && (
+ <>
+ {outline.length === 0 && (
+
outlineGenRef.current?.generateNow()} />
+ )}
+ {outline.length > 0 ? (
+ <>
+
+ blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
+ />
+ >
+ ) : (
+
+
Create Your Outline
+
Use the copilot to generate an outline based on your research.
+
+ )}
+ >
+ )}
+
+ {currentPhase === 'content' && outline.length > 0 && (
+ <>
+ {outlineConfirmed ? (
+
+ ) : (
+
+
Confirm Your Outline
+
Review and confirm your outline before generating content.
+
+ )}
+ >
+ )}
+
+ {currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
+ <>
+ {Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
+
+ ) : (
+
+
Loading Content...
+
Please wait while your content is being optimized.
+
+ )}
+ >
+ )}
+
+ {/* Fallback for SEO phase if conditions not met */}
+ {currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
+
+
Optimize your blog for search engines.
+
Complete the content phase first to enable SEO optimization.
+
+ )}
+
+ {currentPhase === 'publish' && seoAnalysis && seoMetadata && (
+
+
Publish Your Blog
+
Your blog is ready to publish!
+
+ )}
+
+
+ );
+};
+
+export default PhaseContent;
+
+
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/TaskProgressModals.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/TaskProgressModals.tsx
new file mode 100644
index 00000000..34c6fd4e
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/TaskProgressModals.tsx
@@ -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 = ({
+ showOutlineModal,
+ outlinePolling,
+ showModal,
+ rewritePolling,
+ mediumPolling,
+}) => {
+ return (
+ <>
+ m.message)}
+ latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
+ error={outlinePolling.error ?? null}
+ />
+
+ 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;
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/WriterCopilotSidebar.tsx b/frontend/src/components/BlogWriter/BlogWriterUtils/WriterCopilotSidebar.tsx
new file mode 100644
index 00000000..c63ae40b
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/WriterCopilotSidebar.tsx
@@ -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 = ({
+ suggestions,
+ research,
+ outline,
+ outlineConfirmed,
+}) => {
+ return (
+ {
+ 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;
+
+
diff --git a/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterCopilotActions.ts b/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterCopilotActions.ts
new file mode 100644
index 00000000..3213d569
--- /dev/null
+++ b/frontend/src/components/BlogWriter/BlogWriterUtils/useBlogWriterCopilotActions.ts
@@ -0,0 +1,90 @@
+import { useRef } from 'react';
+import { useCopilotAction } from '@copilotkit/react-core';
+import { debug } from '../../../utils/debug';
+
+type ConfirmCb = () => string | Promise;
+type AnalyzeCb = () => string | Promise;
+type OpenMetadataCb = () => void;
+
+interface UseBlogWriterCopilotActionsParams {
+ isSEOAnalysisModalOpen: boolean;
+ lastSEOModalOpenRef: React.MutableRefObject;
+ runSEOAnalysisDirect: AnalyzeCb;
+ confirmBlogContent: ConfirmCb;
+ sections: Record;
+ 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;
+
+
diff --git a/frontend/src/components/BlogWriter/ContinuityBadge.tsx b/frontend/src/components/BlogWriter/ContinuityBadge.tsx
index 2efaa4dd..751e4769 100644
--- a/frontend/src/components/BlogWriter/ContinuityBadge.tsx
+++ b/frontend/src/components/BlogWriter/ContinuityBadge.tsx
@@ -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 = ({ 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]);
diff --git a/frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx b/frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
index 37ae958c..5ff66b4b 100644
--- a/frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
+++ b/frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
@@ -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 = ({
}) => {
const [editingSection, setEditingSection] = useState(null);
const [expandedSections, setExpandedSections] = useState>(new Set());
+ const [hoveredSection, setHoveredSection] = useState(null);
const [showAddSection, setShowAddSection] = useState(false);
+ const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
+ const [sectionImages, setSectionImages] = useState>({});
const [newSectionData, setNewSectionData] = useState({
heading: '',
subheadings: '',
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC = ({
border: '1px solid #e0e0e0',
overflow: 'hidden'
}}>
+ {imageModalState.open && (
+ 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 */}
= ({
{/* Section Header */}
setHoveredSection(section.id)}
+ onMouseLeave={() => setHoveredSection(null)}
onClick={() => toggleExpanded(section.id)}>
= ({
>
βοΈ
+
{
+ 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
+
{
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC = ({
{/* Expanded Section Content */}
- {expandedSections.has(section.id) && (
+ {(expandedSections.has(section.id) || hoveredSection === section.id) && (
{/* Subheadings */}
{section.subheadings && section.subheadings.length > 0 && (
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC
= ({
)}
+
+ {/* Generated Image Display */}
+ {sectionImages[section.id] && (
+
+
+ πΌοΈ Generated Image
+
+
+
+
+
+ )}
+
+
+ {
+ 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
+
+
)}
diff --git a/frontend/src/components/BlogWriter/KeywordInputForm.tsx b/frontend/src/components/BlogWriter/KeywordInputForm.tsx
index 5b1a6a88..19083320 100644
--- a/frontend/src/components/BlogWriter/KeywordInputForm.tsx
+++ b/frontend/src/components/BlogWriter/KeywordInputForm.tsx
@@ -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 (
-
- );
-};
-
export const KeywordInputForm: React.FC = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState(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 (
-
-
- β
Research keywords received! Starting research...
-
-
- );
- }
-
- return (
- {
- 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 (
-
-
-
-
π Researching Your Topic
-
-
-
β’ Connecting to Google Search grounding...
-
β’ Analyzing keywords and search intent...
-
β’ Gathering relevant sources and statistics...
-
β’ Generating content angles and search queries...
-
-
-
- );
- }
- 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 = ({ onKeywordsRe
);
};
-export default KeywordInputForm;
+export default KeywordInputForm;
\ No newline at end of file
diff --git a/frontend/src/components/BlogWriter/OutlineGenerator.tsx b/frontend/src/components/BlogWriter/OutlineGenerator.tsx
index 1a74cf5a..5de43603 100644
--- a/frontend/src/components/BlogWriter/OutlineGenerator.tsx
+++ b/frontend/src/components/BlogWriter/OutlineGenerator.tsx
@@ -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 = ({
+export const OutlineGenerator = forwardRef(({
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 = ({
}
},
render: ({ status }: any) => {
- console.log('generateOutline render called with status:', status);
if (status === 'inProgress' || status === 'executing') {
return (
= ({
});
return null; // This component only provides the copilot action
-};
+});
export default OutlineGenerator;
diff --git a/frontend/src/components/BlogWriter/PhaseNavigation.tsx b/frontend/src/components/BlogWriter/PhaseNavigation.tsx
new file mode 100644
index 00000000..0ea7c412
--- /dev/null
+++ b/frontend/src/components/BlogWriter/PhaseNavigation.tsx
@@ -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
= ({
+ phases,
+ onPhaseClick,
+ currentPhase
+}) => {
+ return (
+
+ {phases.map((phase) => {
+ const isCurrent = phase.current;
+ const isCompleted = phase.completed;
+ const isDisabled = phase.disabled;
+
+ return (
+ !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}
+ >
+
+ {phase.icon}
+
+ {phase.name}
+ {isCompleted && !isCurrent && (
+
+ β
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export default PhaseNavigation;
diff --git a/frontend/src/components/BlogWriter/PhaseNavigationTest.tsx b/frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
new file mode 100644
index 00000000..e4c772a4
--- /dev/null
+++ b/frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
@@ -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('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 (
+
+
Phase Navigation Test
+
Current Phase: {currentPhase}
+
+
+
+
+
Phase Status:
+
+ {testPhases.map(phase => (
+
+ {phase.name} :
+ {phase.completed ? ' β
Completed' : ' β³ Pending'} |
+ {phase.current ? ' π― Current' : ''} |
+ {phase.disabled ? ' π« Disabled' : ' β
Enabled'}
+
+ ))}
+
+
+
+ );
+};
+
+export default PhaseNavigationTest;
diff --git a/frontend/src/components/BlogWriter/ResearchAction.tsx b/frontend/src/components/BlogWriter/ResearchAction.tsx
index 6d946d65..6add8c5e 100644
--- a/frontend/src/components/BlogWriter/ResearchAction.tsx
+++ b/frontend/src/components/BlogWriter/ResearchAction.tsx
@@ -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 = ({ onResearchComple
const [currentTaskId, setCurrentTaskId] = useState(null);
const [currentMessage, setCurrentMessage] = useState('');
const [showProgressModal, setShowProgressModal] = useState(false);
+ const [forceUpdate, setForceUpdate] = useState(0);
+
+ // Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
+ const keywordsRef = useRef(null);
+ const blogLengthRef = useRef(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 = ({ 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 (
+
+
β
Research completed successfully!
+
{latestMessage?.message || 'Research data is now available for your blog.'}
+
+ );
+ }
+
+ if (polling.currentStatus === 'in_progress' || polling.currentStatus === 'running') {
+ return (
+
+
π Research in progress...
+
{currentMessage || 'Gathering research data...'}
+
+ );
+ }
+
+ return (
+
+
π Let's Research Your Blog Topic
+
+ What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
+
+
+
+ Keywords or Topic *
+
+
+
+
+ Blog Length (words)
+
+ 500 words (Short blog)
+ 1000 words (Medium blog)
+ 1500 words (Long blog)
+ 2000 words (Comprehensive blog)
+
+
+
+
+ {
+ 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
+
+
+
+ );
+ }
+ });
+
+ // 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 (
- setShowProgressModal(false)}
- />
+ <>
+ {showProgressModal && (
+ setShowProgressModal(false)}
+ />
+ )}
+ >
);
};
diff --git a/frontend/src/components/BlogWriter/ResearchPollingHandler.tsx b/frontend/src/components/BlogWriter/ResearchPollingHandler.tsx
index b07f7521..69c5843d 100644
--- a/frontend/src/components/BlogWriter/ResearchPollingHandler.tsx
+++ b/frontend/src/components/BlogWriter/ResearchPollingHandler.tsx
@@ -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 = ({
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 = ({
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 = ({
};
}, [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 (
diff --git a/frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx b/frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
index dd920241..a7d6b67b 100644
--- a/frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
+++ b/frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
@@ -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 = ({ 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 (
+
+
+ {keyword}
+
+
+
+ {status}
+
+
+
+
+ );
+ };
+
return (
-
+
Keyword Analysis
{/* Keyword Types Overview */}
-
-
+
+
Keyword Types Found
-
-
+
+
Primary Keywords
-
- {detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
+
+ {keywordData?.primary_keywords?.length || 0} found
- {detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
-
- ))}
+
+ {keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
+
+ ))}
+
-
-
+
+
Long-tail Keywords
-
- {detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
+
+ {keywordData?.long_tail_keywords?.length || 0} found
- {detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
-
- ))}
+
+ {keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
+
+ ))}
+
-
-
+
+
Semantic Keywords
-
- {detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
+
+ {keywordData?.semantic_keywords?.length || 0} found
- {detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
-
- ))}
+
+ {keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
+
+ ))}
+
{/* Keyword Densities */}
-
+
-
+
Keyword Densities
-
+
Keyword Density Analysis
-
+
Shows how frequently each keyword appears in your content as a percentage of total words.
-
+
Optimal Range: 1-3% for primary keywords
-
+
Too Low (<1%): Keyword may not be prominent enough
-
+
Too High (>3%): Risk of keyword stuffing
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC = ({ detailedAnalys
arrow
>
-
+
-
- {detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
- Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
-
- {keyword}
-
-
- {density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
-
- 3 ? 'error' : density < 1 ? 'warning' : 'success'}
- size="small"
- />
-
-
- ))
+
+ {keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
+ Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
) : (
-
+
No keyword density data available. Make sure your research data includes target keywords.
)}
-
+
{/* Missing Keywords */}
- {detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
-
+ {keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
+
-
+
Missing Keywords
-
-
-
+
+
+
- {detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
-
+ {keywordData.missing_keywords.map((keyword) => (
+
))}
)}
-
+
{/* Over-Optimized Keywords */}
- {detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
-
+ {keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
+
-
+
Over-Optimized Keywords
-
-
-
+
+
+
- {detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
-
+ {keywordData.over_optimization.map((keyword) => (
+
))}
)}
{/* Keyword Distribution Analysis */}
- {detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
-
-
+ {keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
+
+
Keyword Distribution Analysis
- {Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
-
-
- "{keyword}"
+ {Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
+
+
+ β{keyword}β
-
+
Density: {data.density?.toFixed(1)}%
-
+
In Headings: {data.in_headings ? 'Yes' : 'No'}
-
+
First Occurrence: Character {data.first_occurrence || 'Not found'}
diff --git a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/CoreMetadataTab.tsx b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/CoreMetadataTab.tsx
index 92c39bf6..d6ee3e25 100644
--- a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/CoreMetadataTab.tsx
+++ b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/CoreMetadataTab.tsx
@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC = ({
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 (
-
+
Core SEO Metadata
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC = ({
{/* SEO Title */}
-
+
-
-
+
+
SEO Title
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC = ({
value={metadata.seo_title || ''}
onChange={handleTextFieldChange('seo_title')}
placeholder="Enter SEO-optimized title (50-60 characters)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC = ({
)
}}
/>
-
- Include your primary keyword and make it compelling for clicks
-
+
+ Include your primary keyword and keep between 50β60 characters
+
{/* Meta Description */}
-
+
-
-
+
+
Meta Description
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC = ({
value={metadata.meta_description || ''}
onChange={handleTextFieldChange('meta_description')}
placeholder="Enter compelling meta description (150-160 characters)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC = ({
)
}}
/>
-
- Include a call-to-action and your primary keyword
-
+
+ Aim for 150β160 characters with a clear value proposition
+
{/* URL Slug */}
-
+
-
-
+
+
URL Slug
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC = ({
onChange={handleTextFieldChange('url_slug')}
placeholder="seo-friendly-url-slug"
helperText="Use lowercase letters, numbers, and hyphens only"
+ sx={textInputSx}
+ FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
{/* Focus Keyword */}
-
+
-
-
+
+
Focus Keyword
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC = ({
onChange={handleTextFieldChange('focus_keyword')}
placeholder="primary-keyword"
helperText="Your main SEO keyword for this post"
+ sx={textInputSx}
+ FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
{/* Blog Tags */}
-
+
-
-
+
+
Blog Tags
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC = ({
- Tags
+ Tags
}
+ input={ }
renderValue={(selected) => (
{selected.map((value: string) => (
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC = ({
))}
-
- Add relevant tags for better categorization and discoverability
-
+
+ Add 3β6 relevant tags for better categorization and discoverability
+
{/* Blog Categories */}
-
+
-
-
+
+
Blog Categories
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC = ({
- Categories
+ Categories
}
+ input={ }
renderValue={(selected) => (
{selected.map((value: string) => (
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC = ({
))}
-
- Select 2-3 primary categories for your content
-
+
+ Select 1β3 primary categories for your content
+
{/* Social Hashtags */}
-
+
-
-
+
+
Social Hashtags
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC = ({
- Hashtags
+ Hashtags
}
+ input={ }
renderValue={(selected) => (
{selected.map((value: string) => (
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC = ({
))}
-
- Include # symbol for social media platforms
-
+
+ Include # symbol (e.g., #multimodalAI). 3β5 hashtags recommended.
+
{/* Reading Time */}
-
+
-
-
+
+
Reading Time
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC = ({
endAdornment: minutes
}}
helperText="Estimated reading time for your content"
+ sx={textInputSx}
+ FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
diff --git a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/PreviewCard.tsx b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/PreviewCard.tsx
index 6ac707d1..67df96ff 100644
--- a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/PreviewCard.tsx
+++ b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/PreviewCard.tsx
@@ -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 = ({
metadata,
- blogTitle
+ blogTitle,
+ previewTabValue,
+ onPreviewTabChange
}) => {
const getCurrentDate = () => {
return new Date().toLocaleDateString('en-US', {
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC = ({
return (
-
+ {/* Title with Tooltip */}
+
- Live Preview
-
+
+ Live Preview
+
+
+
+
+
+
+
-
- {/* Google Search Results Preview */}
-
-
-
-
-
- Google Search Results
+ {/* Platform Sub-Tabs */}
+
+ onPreviewTabChange(newValue)}
+ variant="scrollable"
+ scrollButtons="auto"
+ sx={{
+ '& .MuiTab-root': {
+ textTransform: 'none',
+ fontWeight: 500,
+ minHeight: 48
+ },
+ '& .Mui-selected': {
+ fontWeight: 600
+ }
+ }}
+ >
+ }
+ iconPosition="start"
+ label="Google Search Results"
+ value="google"
+ />
+ }
+ iconPosition="start"
+ label="Facebook Preview"
+ value="facebook"
+ />
+ }
+ iconPosition="start"
+ label="Twitter Preview"
+ value="twitter"
+ />
+ }
+ iconPosition="start"
+ label="Rich Snippets Preview"
+ value="richsnippets"
+ />
+
+
+
+ {/* Google Search Results Preview */}
+ {previewTabValue === 'google' && (
+
+
+
+
+ Google Search Results
+
+
+
+ {/* Google SERP Preview - Light Theme (matches actual Google) */}
+
+
+ {/* URL - Google Blue */}
+
+ {metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
-
-
+
+ {/* Title - Google Blue, hover underline */}
+
+ {metadata.seo_title || blogTitle}
+
+
+ {/* Description - Google Gray */}
+
+ {metadata.meta_description || 'Your meta description will appear here in Google search results...'}
+
+
+ {/* Additional Info */}
+
+
+ {getCurrentDate()}
+
+
+ β’ {metadata.reading_time || 5} min read
+
+ {metadata.blog_tags && metadata.blog_tags.length > 0 && (
+ <>
+
+ β’ {metadata.blog_tags.slice(0, 2).join(', ')}
+
+ >
+ )}
+
+
+
+
+ )}
-
-
+ {/* Facebook Preview */}
+ {previewTabValue === 'facebook' && (
+
+
+
+
+ Facebook Preview
+
+
+
+
+ {/* Facebook Card Preview */}
+
+
+ {/* Image placeholder */}
+
+ {metadata.open_graph?.image ? (
+
+ Image loaded
+
+ ) : (
+
+ No image set
+
+ )}
+
+
+
{/* URL */}
-
- {metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
+
+ {metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
{/* Title */}
- {metadata.seo_title || blogTitle}
+ {metadata.open_graph?.title || metadata.seo_title || blogTitle}
{/* Description */}
-
- {metadata.meta_description || 'Your meta description will appear here in Google search results...'}
+
+ {metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
+
+
+
+
+
+ )}
+
+ {/* Twitter Preview */}
+ {previewTabValue === 'twitter' && (
+
+
+
+
+ Twitter Preview
+
+
+
+
+ {/* Twitter Card Preview */}
+
+
+ {/* Image placeholder */}
+
+ {metadata.twitter_card?.image ? (
+
+ Image loaded
+
+ ) : (
+
+ No image set
+
+ )}
+
+
+
+ {/* URL */}
+
+ {metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
- {/* Additional Info */}
-
-
- {getCurrentDate()}
-
-
- β’
-
-
- {metadata.reading_time || 5} min read
-
- {metadata.blog_tags && metadata.blog_tags.length > 0 && (
- <>
-
- β’
-
-
- {metadata.blog_tags.slice(0, 2).join(', ')}
-
- >
- )}
-
-
-
-
-
- This is how your blog post will appear in Google search results
-
-
-
-
- {/* Social Media Previews */}
-
-
-
-
-
- Facebook Preview
-
-
-
-
-
-
- {/* Image placeholder */}
-
-
- {metadata.open_graph?.image ? 'Image loaded' : 'No image set'}
-
-
-
-
- {/* URL */}
-
- {metadata.canonical_url || 'yourwebsite.com'}
-
-
- {/* Title */}
-
- {metadata.open_graph?.title || metadata.seo_title || blogTitle}
-
-
- {/* Description */}
-
- {metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
-
-
-
-
-
-
-
-
-
-
-
-
- Twitter Preview
-
-
-
-
-
-
- {/* Image placeholder */}
-
-
- {metadata.twitter_card?.image ? 'Image loaded' : 'No image set'}
-
-
-
-
- {/* URL */}
-
- {metadata.canonical_url || 'yourwebsite.com'}
-
-
- {/* Title */}
-
- {metadata.twitter_card?.title || metadata.seo_title || blogTitle}
-
-
- {/* Description */}
-
- {metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
-
-
- {/* Twitter handle */}
- {metadata.twitter_card?.site && (
-
- {metadata.twitter_card.site}
-
- )}
-
-
-
-
-
-
- {/* Rich Snippets Preview */}
-
-
-
-
-
- Rich Snippets Preview
-
-
-
-
-
-
- {/* Article Schema Preview */}
-
-
- {metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
-
-
-
-
-
- {metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
+ {/* Title */}
+
+ {metadata.twitter_card?.title || metadata.seo_title || blogTitle}
-
- {metadata.json_ld_schema?.author?.name && (
-
-
- By {metadata.json_ld_schema.author.name}
-
-
- )}
-
- {metadata.json_ld_schema?.datePublished && (
-
-
- {new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
-
-
- )}
-
- {metadata.reading_time && (
-
-
- {metadata.reading_time} min read
-
-
- )}
-
- {metadata.json_ld_schema?.wordCount && (
-
-
- {metadata.json_ld_schema.wordCount} words
-
-
- )}
-
+ {/* Description */}
+
+ {metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
+
- {metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
-
-
- Keywords:
+ {/* Twitter handle */}
+ {metadata.twitter_card?.site && (
+
+ {metadata.twitter_card.site}
+
+ )}
+
+
+
+
+ )}
+
+ {/* Rich Snippets Preview */}
+ {previewTabValue === 'richsnippets' && (
+
+
+
+
+ Rich Snippets Preview
+
+
+
+
+ {/* Rich Snippets Card */}
+
+
+ {/* Article Schema Preview */}
+
+
+ {metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
+
+
+
+
+
+ {metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
+
+
+
+ {metadata.json_ld_schema?.author?.name && (
+
+
+ By {metadata.json_ld_schema.author.name}
-
- {metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
-
- ))}
-
)}
-
-
-
-
- Rich snippets help search engines understand your content and may display additional information in search results
-
-
-
-
- {/* Metadata Summary */}
-
-
-
-
- Metadata Summary
-
-
-
-
-
-
- {metadata.optimization_score || 0}%
-
-
- Optimization Score
+
+ {metadata.json_ld_schema?.datePublished && (
+
+
+ {new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
+
+
+ )}
+
+ {metadata.reading_time && (
+
+
+ {metadata.reading_time} min read
+
+
+ )}
+
+ {metadata.json_ld_schema?.wordCount && (
+
+
+ {metadata.json_ld_schema.wordCount} words
+
+
+ )}
+
+
+ {metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
+
+
+ Keywords:
+
+ {metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
+
+ ))}
+
-
-
-
-
-
- {metadata.reading_time || 0}
-
-
- Reading Time (min)
-
-
-
-
-
-
-
- {metadata.blog_tags?.length || 0}
-
-
- Tags
-
-
-
-
-
-
-
- {metadata.blog_categories?.length || 0}
-
-
- Categories
-
-
-
-
-
-
-
+ )}
+
+
+
+ )}
);
};
diff --git a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/SocialMediaTab.tsx b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/SocialMediaTab.tsx
index 5225a9de..ec65a93d 100644
--- a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/SocialMediaTab.tsx
+++ b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/SocialMediaTab.tsx
@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC = ({
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 (
-
+
Social Media Metadata
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC = ({
{/* Open Graph Section */}
-
+
-
+
Open Graph Tags
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC = ({
-
+
OG Title
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC = ({
value={openGraph.title || ''}
onChange={handleNestedFieldChange('open_graph', 'title')}
placeholder="Open Graph title (60 characters max)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC = ({
-
+
OG Description
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC = ({
value={openGraph.description || ''}
onChange={handleNestedFieldChange('open_graph', 'description')}
placeholder="Open Graph description (160 characters max)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC = ({
-
+
OG Image URL
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC = ({
value={openGraph.image || ''}
onChange={handleNestedFieldChange('open_graph', 'image')}
placeholder="https://example.com/image.jpg"
+ sx={textInputSx}
InputProps={{
startAdornment: (
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC = ({
-
+
OG URL
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC = ({
value={openGraph.url || ''}
onChange={handleNestedFieldChange('open_graph', 'url')}
placeholder="https://example.com/blog-post"
+ sx={textInputSx}
InputProps={{
startAdornment: (
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC = ({
-
- Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
-
+
+ Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
+
{/* Twitter Card Section */}
-
+
-
+
Twitter Card Tags
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC = ({
-
+
Twitter Title
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC = ({
value={twitterCard.title || ''}
onChange={handleNestedFieldChange('twitter_card', 'title')}
placeholder="Twitter card title (70 characters max)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC = ({
-
+
Twitter Description
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC = ({
value={twitterCard.description || ''}
onChange={handleNestedFieldChange('twitter_card', 'description')}
placeholder="Twitter card description (200 characters max)"
+ sx={textInputSx}
InputProps={{
endAdornment: (
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC = ({
-
+
Twitter Image URL
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC = ({
value={twitterCard.image || ''}
onChange={handleNestedFieldChange('twitter_card', 'image')}
placeholder="https://example.com/twitter-image.jpg"
+ sx={textInputSx}
InputProps={{
startAdornment: (
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC = ({
-
+
Twitter Site Handle
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC = ({
value={twitterCard.site || ''}
onChange={handleNestedFieldChange('twitter_card', 'site')}
placeholder="@yourwebsite"
+ sx={textInputSx}
InputProps={{
startAdornment: (
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC = ({
-
- Twitter cards provide rich previews when your content is shared on Twitter/X
-
+
+ Twitter cards provide rich previews when your content is shared on Twitter/X.
+
{/* Social Media Preview */}
-
-
+
+
Social Media Preview
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC = ({
{/* Facebook Preview */}
-
+
-
+
Facebook Preview
-
-
+
+
{openGraph.title || 'Your Blog Title'}
-
+
{openGraph.url || 'yourwebsite.com'}
-
+
{openGraph.description || 'Your meta description will appear here...'}
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC = ({
{/* Twitter Preview */}
-
+
-
+
Twitter Preview
-
-
+
+
{twitterCard.title || 'Your Blog Title'}
-
+
{twitterCard.site || '@yourwebsite'}
-
+
{twitterCard.description || 'Your Twitter description will appear here...'}
diff --git a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/StructuredDataTab.tsx b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/StructuredDataTab.tsx
index 770c51c0..387ec410 100644
--- a/frontend/src/components/BlogWriter/SEO/MetadataDisplay/StructuredDataTab.tsx
+++ b/frontend/src/components/BlogWriter/SEO/MetadataDisplay/StructuredDataTab.tsx
@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC = ({
}) => {
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) => {
onMetadataEdit(field, event.target.value);
};
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC = ({
{/* Article Information */}
-
+
Article Schema
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC = ({
value={jsonLdSchema.headline || ''}
onChange={handleSchemaFieldChange('headline')}
placeholder="Article headline"
+ sx={textInputSx}
+ InputProps={{
+ endAdornment: (
+
+
+ {getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
+
+
+ )
+ }}
/>
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC = ({
value={jsonLdSchema.description || ''}
onChange={handleSchemaFieldChange('description')}
placeholder="Article description"
+ sx={textInputSx}
+ InputProps={{
+ endAdornment: (
+
+
+ {getCharacterCountText((jsonLdSchema.description || '').length, 200)}
+
+
+ )
+ }}
/>
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC = ({
)
}}
+ sx={textInputSx}
/>
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC = ({
InputProps={{
endAdornment: words
}}
+ sx={textInputSx}
/>
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC = ({
{/* Author Information */}
-
+
Author Information
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC = ({
value={author.name || ''}
onChange={handleAuthorFieldChange('name')}
placeholder="Author Name"
+ sx={textInputSx}
/>
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC = ({
value={author['@type'] || ''}
onChange={handleAuthorFieldChange('@type')}
placeholder="Person"
+ sx={textInputSx}
/>
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC = ({
{/* Publisher Information */}
-
+
Publisher Information
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC = ({
value={publisher.name || ''}
onChange={handlePublisherFieldChange('name')}
placeholder="Publisher Name"
+ sx={textInputSx}
/>
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC = ({
value={publisher.logo || ''}
onChange={handlePublisherFieldChange('logo')}
placeholder="https://example.com/logo.png"
+ sx={textInputSx}
/>
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC = ({
{/* Publication Dates */}
-
+
Publication Dates
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC = ({
value={jsonLdSchema.datePublished || ''}
onChange={handleSchemaFieldChange('datePublished')}
InputLabelProps={{ shrink: true }}
+ sx={textInputSx}
/>
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC = ({
value={jsonLdSchema.dateModified || ''}
onChange={handleSchemaFieldChange('dateModified')}
InputLabelProps={{ shrink: true }}
+ sx={textInputSx}
/>
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC = ({
{/* Keywords */}
-
+
Keywords & Categories
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC = ({
}}
placeholder="keyword1, keyword2, keyword3"
helperText="Separate keywords with commas"
+ sx={textInputSx}
/>
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC = ({
readOnly: true,
sx: {
fontFamily: 'monospace',
- fontSize: '0.875rem'
+ fontSize: '0.875rem',
+ background: '#0f172a',
+ color: '#e2e8f0'
}
}}
sx={{
diff --git a/frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx b/frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
new file mode 100644
index 00000000..21b392e7
--- /dev/null
+++ b/frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
@@ -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;
+ 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 = ({
+ overallScore,
+ overallGrade,
+ statusLabel,
+ categoryScores,
+ getMetricTooltip,
+ getScoreColor
+}) => {
+ const gradeMeta = getGradeMeta(overallGrade);
+
+ return (
+
+
+
+
+ Overall SEO Performance Snapshot
+
+
+
+
+
+
+
+
+
+ {overallScore}
+
+ /100
+
+
+
+ Overall Score
+
+
+
+
+ {overallGrade}
+
+ }
+ sx={{
+ fontWeight: 600,
+ px: 2.2,
+ py: 0.5,
+ letterSpacing: 0.3,
+ color: gradeMeta.color,
+ background: gradeMeta.background
+ }}
+ />
+
+
+
+
+
+ {Object.entries(categoryScores).map(([category, score]) => {
+ const tooltip = getMetricTooltip(category);
+ return (
+
+
+ {tooltip.title}
+
+
+ {tooltip.description}
+
+
+ Methodology: {tooltip.methodology}
+
+
+ Score Meaning: {tooltip.score_meaning}
+
+
+ Examples: {tooltip.examples}
+
+
+ }
+ arrow
+ placement="top"
+ >
+
+
+ {score}
+
+
+ {category.replace('_', ' ')}
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+};
+
+export default OverallScoreCard;
diff --git a/frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx b/frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
index d6b08b90..e6d0db7e 100644
--- a/frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
+++ b/frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
@@ -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 = ({
- 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 = ({
+ detailedAnalysis,
+ visualizationData
}) => {
+ const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
+
+ const getMetricDetails = (metric: string, value: number) => {
+ const tooltips: Record = {
+ 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) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+
return (
-
+
Readability Analysis
+
-
+
-
+
Readability Metrics
-
+
Readability Analysis
-
+
Measures how easy your content is to read and understand.
-
+
Flesch Reading Ease: 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
-
- Average Sentence Length: 15-20 words is optimal
+
+ Sentence Length: 15-20 words is optimal
-
- Average Syllables per Word: 1.5-1.7 is ideal
+
+ Syllables per Word: 1.5-1.7 keeps content approachable
}
arrow
>
-
+
-
- {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);
+
+
+ {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 (
-
- {metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
+
+ {label}
-
- {tooltip.description}
+
+ {description}
-
- Interpretation: {tooltip.interpretation}
+
+ Interpretation: {interpretation}
}
arrow
+ placement="top"
>
-
-
+
+
{metric.replace('_', ' ')}
-
+
{value.toFixed(1)}
@@ -167,116 +244,72 @@ export const ReadabilityAnalysis: React.FC = ({
);
})
) : (
-
+
No readability metrics available. This may indicate an issue with the content analysis.
)}
-
+
-
-
+
+
Content Statistics
-
-
- Word Count
-
- {detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
-
-
-
- Sections
-
- {detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
-
-
-
- Paragraphs
-
- {detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
-
-
-
- Sentences
-
- {detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
-
-
-
- Unique Words
-
- {detailedAnalysis?.content_quality?.unique_words || 'N/A'}
-
-
-
- Vocabulary Diversity
-
- {detailedAnalysis?.content_quality?.vocabulary_diversity ?
- (detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
-
-
+
+ {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'
+ )}
- {/* Additional Readability Metrics */}
-
-
+
+
Sentence & Paragraph Analysis
-
-
- Avg Sentence Length
-
- {detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
-
-
-
- Avg Paragraph Length
-
- {detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
-
-
-
- Transition Words
-
- {detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
-
-
+
+ {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'
+ )}
-
+
-
-
+
+
Target Audience
-
-
- Reading Level
-
- {detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
-
-
-
- Content Depth Score
-
- {detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
-
-
-
- Flow Score
-
- {detailedAnalysis?.content_quality?.flow_score || 'N/A'}
-
-
+
+ {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')}
diff --git a/frontend/src/components/BlogWriter/SEO/Recommendations.tsx b/frontend/src/components/BlogWriter/SEO/Recommendations.tsx
index 7e096c20..a4d1e592 100644
--- a/frontend/src/components/BlogWriter/SEO/Recommendations.tsx
+++ b/frontend/src/components/BlogWriter/SEO/Recommendations.tsx
@@ -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 = {
+ 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 = ({ 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 ;
- case 'Medium': return ;
- case 'Low': return ;
- default: return ;
+ case 'High':
+ return ;
+ case 'Medium':
+ return ;
+ case 'Low':
+ return ;
+ default:
+ return ;
}
};
- 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 (
-
+
Actionable Recommendations
-
- {recommendations.map((rec, index) => (
-
-
-
- {getPriorityIcon(rec.priority)}
-
-
-
-
-
+
+ {recommendations.map((rec, index) => {
+ const styles = priorityStyles[rec.priority] || priorityStyles.default;
+ return (
+
+
+
+ {getPriorityIcon(rec.priority)}
+
+
+
+
+
+
+
+ {rec.recommendation}
+
+
+ {rec.impact}
+
-
- {rec.recommendation}
-
-
- {rec.impact}
-
-
-
- ))}
+
+ );
+ })}
);
diff --git a/frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx b/frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
index 82356711..bdc33771 100644
--- a/frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
+++ b/frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
@@ -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 = ({ detailedAnalysis }) => {
+ const structure = detailedAnalysis?.content_structure;
+ const quality = detailedAnalysis?.content_quality;
+ const headings = detailedAnalysis?.heading_structure;
+
return (
-
+
Content Structure Analysis
{/* Content Structure Overview */}
-
-
+
+
Structure Overview
-
+
-
+
Total Sections
-
+
Number of main content sections (H2 headings) in your blog post.
-
+
Optimal Range: 3-8 sections for most blog posts
-
- Why it matters: Good sectioning improves readability and helps search engines understand your content structure.
+
+ Why it matters: Good sectioning improves readability and structure.
}
arrow
>
-
- Total Sections
-
- {detailedAnalysis?.content_structure?.total_sections || 'N/A'}
+
+ Total Sections
+
+ {structure?.total_sections || 'N/A'}
-
+
-
+
Total Paragraphs
-
+
Number of paragraphs in your content (excluding headings).
-
+
Optimal Range: 8-20 paragraphs for most blog posts
-
- Why it matters: Appropriate paragraph count indicates good content depth and organization.
-
}
arrow
>
-
- Total Paragraphs
-
- {detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
+
+ Total Paragraphs
+
+ {structure?.total_paragraphs || 'N/A'}
-
+
-
+
Total Sentences
-
+
Total number of sentences in your content.
-
+
Optimal Range: 40-100 sentences for most blog posts
-
- Why it matters: Sentence count affects readability and content comprehensiveness.
-
}
arrow
>
-
- Total Sentences
-
- {detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
+
+ Total Sentences
+
+ {structure?.total_sentences || 'N/A'}
-
+
-
+
Structure Score
-
+
Overall score (0-100) for your content's structural organization.
-
- Scoring Factors: Section count, paragraph count, introduction/conclusion presence
-
-
- Why it matters: Well-structured content ranks better and provides better user experience.
+
+ Scoring Factors: Section count, paragraph count, intro/conclusion presence.
}
arrow
>
-
- Structure Score
-
- {detailedAnalysis?.content_structure?.structure_score || 'N/A'}
+
+ Structure Score
+
+ {structure?.structure_score || 'N/A'}
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC = ({ detailedAn
{/* Content Elements */}
-
-
+
+
Content Elements
-
+
-
- Introduction Section
-
-
- Whether your content has a clear introduction that sets context and expectations.
-
-
- Why it matters: Introductions help readers understand what they'll learn and improve engagement.
-
-
- SEO Impact: Clear introductions help search engines understand your content's purpose.
-
-
- }
+ title="Whether your content has a clear introduction that sets context and expectations."
arrow
>
-
- Has Introduction
-
+ Has Introduction
+
-
+
-
- Conclusion Section
-
-
- Whether your content has a clear conclusion that summarizes key points.
-
-
- Why it matters: Conclusions help readers retain information and provide closure.
-
-
- SEO Impact: Good conclusions can improve time on page and reduce bounce rate.
-
-
- }
+ title="Whether your content ends with a clear conclusion summarizing key points."
arrow
>
-
- Has Conclusion
-
+ Has Conclusion
+
-
+
-
- Call to Action
-
-
- Whether your content includes a clear call to action for readers.
-
-
- Why it matters: CTAs guide readers to take desired actions and improve conversion rates.
-
-
- SEO Impact: Strong CTAs can improve user engagement metrics.
-
-
- }
+ title="Whether your content includes a clear call to action for readers."
arrow
>
-
- Has Call to Action
-
+ Has Call to Action
+
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC = ({ detailedAn
{/* Content Quality Metrics */}
-
-
+
+
Content Quality Metrics
-
- Word Count
-
-
- Total number of words in your content.
-
-
- Optimal Range: 800-2000 words for most blog posts
-
-
- Why it matters: Longer content typically ranks better and provides more value to readers.
-
-
- }
+ title="Total number of words in your content. Longer content typically ranks better."
arrow
>
-
-
+
+
Word Count
-
- {detailedAnalysis?.content_quality?.word_count || 'N/A'}
+
+ {quality?.word_count || 'N/A'}
-
+
-
- Vocabulary Diversity
-
-
- Ratio of unique words to total words, indicating content variety.
-
-
- Optimal Range: 0.4-0.7 (40-70% unique words)
-
-
- Why it matters: Higher diversity indicates richer, more engaging content.
-
-
- }
+ title="Ratio of unique words to total words, indicating content variety and richness."
arrow
>
-
-
+
+
Vocabulary Diversity
-
- {detailedAnalysis?.content_quality?.vocabulary_diversity ?
- (detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
+
+ {quality?.vocabulary_diversity !== undefined
+ ? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
+ : 'N/A'}
-
+
-
- Content Depth Score
-
-
- Score (0-100) indicating how comprehensive and detailed your content is.
-
-
- Scoring Factors: Word count, section depth, information density
-
-
- Why it matters: Deeper content provides more value and ranks better in search results.
-
-
- }
+ title="Score (0-100) indicating how comprehensive and detailed your content is."
arrow
>
-
-
+
+
Content Depth Score
-
- {detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
+
+ {quality?.content_depth_score || 'N/A'}
-
+
-
- Flow Score
-
-
- Score (0-100) indicating how well your content flows from one idea to the next.
-
-
- Scoring Factors: Transition words, sentence variety, logical progression
-
-
- Why it matters: Good flow improves readability and keeps readers engaged.
-
-
- }
+ title="Score (0-100) indicating how well your content flows from one idea to the next."
arrow
>
-
-
+
+
Flow Score
-
- {detailedAnalysis?.content_quality?.flow_score || 'N/A'}
+
+ {quality?.flow_score || 'N/A'}
-
+
-
- Transition Words
-
-
- Number of transition words used to connect ideas and improve flow.
-
-
- Optimal Range: 5-15 transition words for most blog posts
-
-
- Why it matters: Transition words improve readability and help readers follow your logic.
-
-
- }
+ title="Number of transition words used β higher values suggest smoother narrative flow."
arrow
>
-
-
- Transition Words
+
+
+ Transition Words Used
-
- {detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
+
+ {quality?.transition_words_used || 'N/A'}
-
+
-
- Unique Words
-
-
- Number of unique words used in your content.
-
-
- Why it matters: More unique words indicate richer vocabulary and better content variety.
-
-
- SEO Impact: Diverse vocabulary can help with semantic SEO and topic coverage.
-
-
- }
+ title="Average unique words used throughout the article. Indicates lexical richness."
arrow
>
-
-
+
+
Unique Words
-
- {detailedAnalysis?.content_quality?.unique_words || 'N/A'}
+
+ {quality?.unique_words || 'N/A'}
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC = ({ detailedAn
{/* Heading Structure */}
-
-
-
-
- Heading Structure Analysis
-
-
-
-
-
- H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
-
- {detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
-
- β’ {heading}
+ {headings && (
+
+
+
+
+ Heading Structure
+
+
+
+
+
+ H1 Headings
- ))}
-
-
-
-
-
- H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
-
- {detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
-
- β’ {heading}
+
+ {headings.h1_count}
- ))}
- {detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
-
- ... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
-
- )}
-
-
-
-
-
- H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
-
- {detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
-
- β’ {heading}
-
- ))}
- {detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
-
- ... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
-
- )}
-
-
-
-
-
-
- Heading Hierarchy Score
-
-
- Score (0-100) indicating how well your heading structure follows SEO best practices.
-
-
- Scoring Factors: H1 presence, logical hierarchy, keyword usage in headings
-
-
- Why it matters: Good heading structure helps search engines understand your content and improves readability.
+
+ {headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
- }
- arrow
- >
-
- Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
-
-
-
-
- {/* Structure Recommendations */}
- {detailedAnalysis?.content_structure?.recommendations && detailedAnalysis.content_structure.recommendations.length > 0 && (
-
-
- Structure Recommendations
-
-
- {detailedAnalysis.content_structure.recommendations.map((recommendation: string, index: number) => (
-
- β’ {recommendation}
+
+
+
+
+ H2 Headings
- ))}
-
-
- )}
-
- {/* Heading Recommendations */}
- {detailedAnalysis?.heading_structure?.recommendations && detailedAnalysis.heading_structure.recommendations.length > 0 && (
-
-
- Heading Recommendations
-
-
- {detailedAnalysis.heading_structure.recommendations.map((recommendation: string, index: number) => (
-
- β’ {recommendation}
+
+ {headings.h2_count}
- ))}
-
-
- )}
-
- {/* Content Quality Recommendations */}
- {detailedAnalysis?.content_quality?.recommendations && detailedAnalysis.content_quality.recommendations.length > 0 && (
-
-
- Content Quality Recommendations
-
-
- {detailedAnalysis.content_quality.recommendations.map((recommendation: string, index: number) => (
-
- β’ {recommendation}
+
+ {headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
- ))}
-
-
- )}
-
+
+
+
+
+
+ H3 Headings
+
+
+ {headings.h3_count}
+
+
+ {headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
+
+
+
+
+
+
-
+ )}
);
};
diff --git a/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx b/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
index 3534febe..f6cf64c4 100644
--- a/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
+++ b/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
@@ -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;
+ onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
+}
+
+// Simple content hashing helper (SHA-256)
+async function hashContent(text: string): Promise {
+ 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 = ({
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC = ({
blogContent,
blogTitle,
researchData,
- onApplyRecommendations
+ onApplyRecommendations,
+ onAnalysisComplete
}) => {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisResult, setAnalysisResult] = useState(null);
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC = ({
const [progressMessage, setProgressMessage] = useState('');
const [error, setError] = useState(null);
const [tabValue, setTabValue] = useState('recommendations');
+ const [contentHash, setContentHash] = useState('');
+ const [isApplying, setIsApplying] = useState(false);
+ const [applyError, setApplyError] = useState(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 = ({
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 = ({
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 = ({
};
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 = ({
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 = ({
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
};
- useEffect(() => {
- if (isOpen && !analysisResult) {
- runSEOAnalysis();
- }
- }, [isOpen, analysisResult, runSEOAnalysis]);
-
return (
= ({
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'
}
}}
>
-
+
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC = ({
SEO Analysis Results
-
-
-
+
+ }
+ onClick={() => {
+ setAnalysisResult(null);
+ runSEOAnalysis(true);
+ }}
+ >
+ Refresh
+
+
+
+
+
Comprehensive analysis of your blog content's SEO optimization
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC = ({
{analysisResult && (
{/* Overall Score Section */}
-
-
-
-
-
- Overall SEO Score
-
-
-
-
-
-
-
-
- {analysisResult.overall_score}
-
-
- Overall Score
-
-
-
-
-
-
- {analysisResult.analysis_summary.overall_grade}
-
-
- Grade
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Category Scores */}
-
-
-
- Category Breakdown
-
-
-
-
- {Object.entries(analysisResult.category_scores).map(([category, score]) => {
- const tooltip = getMetricTooltip(category);
- return (
-
-
-
- {tooltip.title}
-
-
- {tooltip.description}
-
-
- Methodology: {tooltip.methodology}
-
-
- Score Meaning: {tooltip.score_meaning}
-
-
- Examples: {tooltip.examples}
-
-
- }
- arrow
- placement="top"
- >
-
-
- {score}
-
-
- {category.replace('_', ' ')}
-
-
-
-
- );
- })}
-
-
-
+
{/* Detailed Analysis Tabs */}
@@ -603,43 +545,41 @@ export const SEOAnalysisModal: React.FC = ({
-
+
AI-Powered Insights
-
-
+
+
Content Summary
-
+
{analysisResult.analysis_summary.ai_summary}
-
-
-
+
+
Key Strengths
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
- {strength}
+ {strength}
))}
-
-
-
+
+
Areas for Improvement
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
- {weakness}
+ {weakness}
))}
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC = ({
{/* Action Buttons */}
+ {applyError && (
+
+
+ {applyError}
+
+ )}
-
+
Close
{
- 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 = ({
}
}}
>
- Apply Recommendations
+ {isApplying ? (
+
+
+ Applying...
+
+ ) : (
+ 'Apply Recommendations'
+ )}
diff --git a/frontend/src/components/BlogWriter/SEOMetadataModal.tsx b/frontend/src/components/BlogWriter/SEOMetadataModal.tsx
index 2d8a5c4d..028d89ad 100644
--- a/frontend/src/components/BlogWriter/SEOMetadataModal.tsx
+++ b/frontend/src/components/BlogWriter/SEOMetadataModal.tsx
@@ -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 {
+ 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 = ({
isOpen,
onClose,
blogContent,
blogTitle,
researchData,
+ outline,
+ seoAnalysis,
onMetadataGenerated
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [metadataResult, setMetadataResult] = useState(null);
const [error, setError] = useState(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>(new Set());
const [editableMetadata, setEditableMetadata] = useState(null);
+ const [contentHash, setContentHash] = useState('');
+ // 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 = ({
});
}, [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 = ({
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 = ({
} 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 = ({
}
};
+ /**
+ * 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 = ({
/>
)}
-
-
-
+
+ {metadataResult && (
+
+ generateMetadata(true)}
+ size="small"
+ disabled={isGenerating}
+ color="primary"
+ >
+
+
+
+ )}
+
+
+
+
- {!metadataResult && !isGenerating && (
-
-
- Generate Comprehensive SEO Metadata
-
-
- Create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.
-
- }
- sx={{ px: 4 }}
- >
- Generate SEO Metadata
-
-
- )}
-
{isGenerating && (
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC = ({
generateMetadata(true)}
startIcon={ }
>
Try Again
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC = ({
scrollButtons="auto"
sx={{ minHeight: 48 }}
>
- {['core', 'social', 'structured', 'preview'].map((tab) => (
+ {['preview', 'core', 'social', 'structured'].map((tab) => (
= ({
)}
diff --git a/frontend/src/components/BlogWriter/SEOMiniPanel.tsx b/frontend/src/components/BlogWriter/SEOMiniPanel.tsx
index e691069a..1d8776f8 100644
--- a/frontend/src/components/BlogWriter/SEOMiniPanel.tsx
+++ b/frontend/src/components/BlogWriter/SEOMiniPanel.tsx
@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC = ({ analysis }) => {
return (
SEO Mini Panel
-
Score: {analysis.seo_score}
- {!!analysis.recommendations?.length && (
+
Score: {analysis.overall_score}
+ {!!analysis.analysis_summary && (
+
+ Grade {analysis.analysis_summary.overall_grade} Β· {analysis.analysis_summary.status}
+
+ )}
+ {!!analysis.actionable_recommendations?.length && (
- {analysis.recommendations.slice(0, 3).map((r, i) => ({r} ))}
+ {analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
+
+ {rec.category}: {rec.recommendation}
+
+ ))}
)}
diff --git a/frontend/src/components/BlogWriter/SuggestionsGenerator.tsx b/frontend/src/components/BlogWriter/SuggestionsGenerator.tsx
index add76920..a99ca3f7 100644
--- a/frontend/src/components/BlogWriter/SuggestionsGenerator.tsx
+++ b/frontend/src/components/BlogWriter/SuggestionsGenerator.tsx
@@ -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 = ({ research, outline, outlineConfirmed = false }) => {
- useSuggestions(research, outline, outlineConfirmed);
+ useSuggestions({ research, outline, outlineConfirmed });
return null; // This is just a utility component
};
diff --git a/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx b/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
index 57b93810..e0fe8b51 100644
--- a/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
+++ b/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
@@ -70,8 +70,21 @@ const BlogSection: React.FC = ({
// 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 = ({
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
}
}
);
diff --git a/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx b/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx
index 640a702c..2de083d5 100644
--- a/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx
+++ b/frontend/src/components/BlogWriter/WYSIWYG/BlogTextSelectionHandler.tsx
@@ -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;
@@ -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}
/>
)
};
diff --git a/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx b/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx
index 45a39693..6ba7b747 100644
--- a/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx
+++ b/frontend/src/components/BlogWriter/WYSIWYG/SmartTypingAssist.tsx
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
+import { debug } from '../../../utils/debug';
interface SmartTypingAssistProps {
contentRef: React.RefObject;
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
const [allSuggestions, setAllSuggestions] = useState([]);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
+ const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
const typingTimeoutRef = useRef(null);
+ const lastGeneratedAtRef = useRef(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
};
diff --git a/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx b/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx
index 44fcdcd9..a2c954f1 100644
--- a/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx
+++ b/frontend/src/components/BlogWriter/WYSIWYG/TextSelectionMenu.tsx
@@ -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 = ({
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC = ({
isGeneratingSuggestion,
allSuggestions,
suggestionIndex,
+ showContinueWritingPrompt,
onCheckFacts,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
onRejectSuggestion,
- onNextSuggestion
+ onNextSuggestion,
+ onRequestSuggestion,
+ onDismissPrompt
}) => {
return (
<>
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC = ({
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 = ({
)}
+ {/* Continue Writing Prompt */}
+ {showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
+
+
+ β¨ AI Writing Assistant
+
+
+ ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
+
+
+ {
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
+ }}
+ >
+ βοΈ Continue Writing
+
+ {
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
+ }}
+ >
+ β
+
+
+
+ )}
+
{/* CSS for spinner animation */}