Added image generation to blog writer

This commit is contained in:
ajaysi
2025-10-31 15:59:16 +05:30
parent 3219e6bbe4
commit cdb41aec1b
80 changed files with 7662 additions and 3951 deletions

View File

@@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { debug } from '../../utils/debug';
import { CopilotSidebar } from '@copilotkit/react-ui';
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
import { useCopilotAction } from '@copilotkit/react-core';
import '@copilotkit/react-ui/styles.css';
import { blogWriterApi } from '../../services/blogWriterApi';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../services/blogWriterApi';
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
@@ -26,10 +29,16 @@ import OutlineRefiner from './OutlineRefiner';
import { SEOProcessor } from './SEO';
import BlogWriterLanding from './BlogWriterLanding';
import { OutlineProgressModal } from './OutlineProgressModal';
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
import OutlineFeedbackForm from './OutlineFeedbackForm';
import { BlogEditor } from './WYSIWYG';
import { SEOAnalysisModal } from './SEOAnalysisModal';
import { SEOMetadataModal } from './SEOMetadataModal';
import PhaseNavigation from './PhaseNavigation';
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
import HeaderBar from './BlogWriterUtils/HeaderBar';
import PhaseContent from './BlogWriterUtils/PhaseContent';
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
// Type assertion for CopilotKit action
const useCopilotActionTyped = useCopilotAction as any;
@@ -59,6 +68,7 @@ export const BlogWriter: React.FC = () => {
flowAnalysisResults,
setOutline,
setTitleOptions,
setSelectedTitle,
setSections,
setSeoAnalysis,
setGenMode,
@@ -79,6 +89,227 @@ export const BlogWriter: React.FC = () => {
handleContentSave
} = useBlogWriterState();
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Phase navigation hook
const {
phases,
currentPhase,
navigateToPhase,
resetUserSelection
} = usePhaseNavigation(
research,
outline,
outlineConfirmed,
Object.keys(sections).length > 0,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied
);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = (): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
return "SEO analysis is already open.";
}
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
if (!contentConfirmed) {
setContentConfirmed(true);
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
}
setSeoRecommendationsApplied(false);
if (!isSEOAnalysisModalOpen) {
setIsSEOAnalysisModalOpen(true);
lastSEOModalOpenRef.current = now;
debug.log('[BlogWriter] SEO modal opened (direct)');
}
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
};
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
) => {
if (!outline || outline.length === 0) {
throw new Error('An outline is required before applying recommendations.');
}
const sectionPayload = outline.map((section) => ({
id: section.id,
heading: section.heading,
content: sections[section.id] ?? '',
}));
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
sections: sectionPayload,
outline,
research: (research as any) || {},
recommendations,
});
if (!response.success) {
throw new Error(response.error || 'Failed to apply recommendations.');
}
if (!response.sections || !Array.isArray(response.sections)) {
throw new Error('Recommendation response did not include updated sections.');
}
// Update sections - create new object reference to trigger React re-render
const newSections: Record<string, string> = {};
response.sections.forEach((section) => {
if (section.id && section.content) {
newSections[section.id] = section.content;
}
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
throw new Error('No valid sections received from SEO recommendations application.');
}
// Validate sections have actual content
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
if (sectionsWithContent.length === 0) {
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
}
// Log detailed section info for debugging
const sectionIds = Object.keys(newSections);
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
sectionCount: sectionIds.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
if (response.title && response.title !== selectedTitle) {
setSelectedTitle(response.title);
}
if (response.applied) {
setSeoAnalysis(prev => prev ? { ...prev, applied_recommendations: response.applied } : prev);
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
}
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
setSeoAnalysis(analysis);
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
}, [setSeoAnalysis]);
// Handle SEO modal close - mark SEO phase as done if not already marked
const handleSEOModalClose = useCallback(() => {
// Mark SEO phase as done when modal closes (even without applying recommendations)
if (!seoAnalysis) {
// Set a minimal valid seoAnalysis object to mark phase as complete
setSeoAnalysis({
success: true,
overall_score: 0,
category_scores: {},
analysis_summary: {
overall_grade: 'N/A',
status: 'Skipped',
strongest_category: 'N/A',
weakest_category: 'N/A',
key_strengths: [],
key_weaknesses: [],
ai_summary: 'SEO analysis was skipped by user'
},
actionable_recommendations: [],
generated_at: new Date().toISOString()
});
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
}
setIsSEOAnalysisModalOpen(false);
debug.log('[BlogWriter] SEO modal closed');
}, [seoAnalysis, setSeoAnalysis, setIsSEOAnalysisModalOpen]);
// Mark SEO phase as completed when recommendations are applied
useEffect(() => {
if (seoRecommendationsApplied && seoAnalysis) {
// SEO phase is considered complete when recommendations are applied
// But stay in SEO phase to show updated content
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
// Ensure we stay in SEO phase to show updated content (override auto-progression)
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
}
}
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
// Track when outlines/content become available for the first time
const prevOutlineLenRef = useRef<number>(outline.length);
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
useEffect(() => {
const prevLen = prevOutlineLenRef.current;
if (research && prevLen === 0 && outline.length > 0) {
resetUserSelection();
}
prevOutlineLenRef.current = outline.length;
}, [research, outline.length, resetUserSelection]);
// Only reset user selection when transitioning from not-confirmed to confirmed
useEffect(() => {
const wasConfirmed = prevOutlineConfirmedRef.current;
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
resetUserSelection(); // Allow auto-progression to content phase
}
prevOutlineConfirmedRef.current = outlineConfirmed;
}, [outlineConfirmed, sections, resetUserSelection]);
useEffect(() => {
const wasConfirmed = prevContentConfirmedRef.current;
if (!wasConfirmed && contentConfirmed && seoAnalysis) {
resetUserSelection(); // Allow auto-progression to SEO phase
}
prevContentConfirmedRef.current = contentConfirmed;
}, [contentConfirmed, seoAnalysis, resetUserSelection]);
// Custom hooks for complex functionality
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
outline,
@@ -139,28 +370,63 @@ export const BlogWriter: React.FC = () => {
onError: (err) => console.error('Rewrite failed:', err)
});
// Get context-aware suggestions based on current task status
const suggestions = useSuggestions(
research,
outline,
outlineConfirmed,
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
Object.keys(sections).length > 0, // hasContent
flowAnalysisCompleted, // flowAnalysisCompleted state
contentConfirmed // contentConfirmed state
);
// Add minimum display time for modal
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
const [showOutlineModal, setShowOutlineModal] = useState(false);
// SEO Analysis Modal state
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const suggestions = useSuggestions({
research,
outline,
outlineConfirmed,
researchPolling: { isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
outlinePolling: { isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
mediumPolling: { isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
hasContent: Object.keys(sections).length > 0,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied,
});
// Drive CopilotKit suggestions programmatically
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
const setSuggestionsRef = useRef<any>(null);
useEffect(() => {
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
}, [copilotHeadless]);
const suggestionsPayload = React.useMemo(
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
[suggestions]
);
const prevSuggestionsRef = useRef<string>("__init__");
const suggestionsJson = React.useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
useEffect(() => {
try {
if (!setSuggestionsRef.current) return;
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch {}
}, [suggestionsJson, suggestionsPayload]);
const handlePhaseClick = useCallback((phaseId: string) => {
navigateToPhase(phaseId);
if (phaseId === 'seo') {
if (seoAnalysis) {
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
} else {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect]);
const outlineGenRef = useRef<any>(null);
useEffect(() => {
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
@@ -214,96 +480,73 @@ export const BlogWriter: React.FC = () => {
progressCount: mediumPolling.progressMessages.length
});
// Debug SEO modal state
console.log('🔍 SEO Analysis Modal state:', {
isSEOAnalysisModalOpen,
hasResearch: !!research,
hasContent: !!sections && Object.keys(sections).length > 0,
researchKeys: research ? Object.keys(research) : [],
sectionsKeys: sections ? Object.keys(sections) : []
});
// Log critical state changes only (reduce noise)
const lastPhaseRef = useRef<string>('');
const lastSeoOpenRef = useRef<boolean>(false);
const lastSectionsLenRef = useRef<number>(0);
// Debug action registration
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
// Copilot action for confirming blog content
useCopilotActionTyped({
name: "confirmBlogContent",
description: "Confirm that the blog content is ready and move to the next stage (SEO analysis)",
parameters: [],
handler: async () => {
console.log('Blog content confirmed by user');
setContentConfirmed(true);
return "Blog content has been confirmed! You can now proceed with SEO analysis and publishing.";
useEffect(() => {
if (currentPhase !== lastPhaseRef.current) {
debug.log('[BlogWriter] Phase changed', { currentPhase });
lastPhaseRef.current = currentPhase;
}
});
}, [currentPhase]);
// Copilot action for running SEO analysis
useCopilotActionTyped({
name: "analyzeSEO",
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
parameters: [],
handler: async () => {
console.log('🚀 SEO Analysis Action Triggered!');
console.log('Current modal state before:', isSEOAnalysisModalOpen);
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
console.log('Research data available:', !!research && !!research.keyword_analysis);
// Check if we have content to analyze
if (!sections || Object.keys(sections).length === 0) {
console.log('❌ No content available for SEO analysis');
return "No blog content available for SEO analysis. Please generate content first.";
useEffect(() => {
const open = isSEOAnalysisModalOpen;
if (open !== lastSeoOpenRef.current) {
debug.log('[BlogWriter] SEO modal', { isOpen: open });
lastSeoOpenRef.current = open;
}
}, [isSEOAnalysisModalOpen]);
useEffect(() => {
const len = Object.keys(sections || {}).length;
if (len !== lastSectionsLenRef.current) {
debug.log('[BlogWriter] Sections updated', { count: len });
lastSectionsLenRef.current = len;
}
}, [sections]);
useEffect(() => {
debug.log('[BlogWriter] Suggestions updated', { suggestions });
}, [suggestions]);
// Force-sync Copilot suggestions right after SEO recommendations applied (guarded by previous suggestions key)
useEffect(() => {
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
try {
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
// Check if we have research data
if (!research || !research.keyword_analysis) {
console.log('❌ No research data available for SEO analysis');
return "Research data is required for SEO analysis. Please run research first.";
}
// Open SEO analysis modal
console.log('✅ All checks passed, opening SEO analysis modal');
} catch (e) {
console.error('Failed to push Copilot suggestions after SEO apply:', e);
}
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
const confirmBlogContentCb = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');
setContentConfirmed(true);
resetUserSelection();
setSeoRecommendationsApplied(false);
navigateToPhase('seo');
setTimeout(() => {
setIsSEOAnalysisModalOpen(true);
console.log('Modal state set to true');
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
}
});
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
}, 0);
return "✅ Blog content has been confirmed! Running SEO analysis now.";
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
// Generate SEO Metadata Action
useCopilotActionTyped({
name: "generateSEOMetadata",
description: "Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data",
parameters: [
{
name: "title",
type: "string",
description: "Optional blog title to use for metadata generation",
required: false
}
],
handler: async ({ title }: { title?: string }) => {
console.log('🚀 Generate SEO Metadata Action Triggered!');
console.log('Title provided:', title);
console.log('Selected title:', selectedTitle);
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
console.log('Research data available:', !!research && !!research.keyword_analysis);
// Check if we have content to generate metadata for
if (!sections || Object.keys(sections).length === 0) {
return "Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.";
}
if (!research || !research.keyword_analysis) {
return "Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.";
}
// Open the SEO metadata modal
setIsSEOMetadataModalOpen(true);
console.log('SEO Metadata modal opened');
return "Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.";
}
useBlogWriterCopilotActions({
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent: confirmBlogContentCb,
sections,
research,
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
});
@@ -366,6 +609,7 @@ export const BlogWriter: React.FC = () => {
{/* New extracted functionality components */}
<OutlineGenerator
ref={outlineGenRef}
research={research}
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
@@ -395,241 +639,70 @@ export const BlogWriter: React.FC = () => {
{!research ? (
<BlogWriterLanding
onStartWriting={() => {
// This will trigger the copilot to start the research process
// The user can then interact with the copilot to begin research
// Trigger the copilot to start the research process
}}
/>
) : (
<>
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{research && outline.length === 0 && <ResearchResults research={research} />}
{outline.length > 0 && (
<div>
{outlineConfirmed ? (
/* WYSIWYG Editor - Show when outline is confirmed */
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
/* Outline Editor - Show when outline is not confirmed */
<>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
</>
)}
</div>
)}
</div>
</div>
<HeaderBar
phases={phases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
/>
<PhaseContent
currentPhase={currentPhase}
research={research}
outline={outline}
outlineConfirmed={outlineConfirmed}
titleOptions={titleOptions}
selectedTitle={selectedTitle}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
setOutline={setOutline}
sections={sections}
handleContentUpdate={handleContentUpdate}
handleContentSave={handleContentSave}
continuityRefresh={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
outlineGenRef={outlineGenRef}
blogWriterApi={blogWriterApi}
contentConfirmed={contentConfirmed}
seoAnalysis={seoAnalysis}
seoMetadata={seoMetadata}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
</>
)}
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.'
}}
<WriterCopilotSidebar
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
// Get current state information
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch ? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
angles: research.suggested_angles?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational'
} : null;
const outlineContext = hasOutline ? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map(s => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline.map(s => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`).join('; ')}
` : '';
const toolGuide = `
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
CURRENT STATE:
${hasResearch && researchInfo ? `
✅ RESEARCH COMPLETED:
- Found ${researchInfo.sources} sources with Google Search grounding
- Generated ${researchInfo.queries} search queries
- Created ${researchInfo.angles} content angles
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:
- getResearchKeywords(prompt?: string) - Get keywords from user for research
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
- researchTopic(keywords: string, industry?: string, target_audience?: string)
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
- generateOutline()
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
- refineOutline(prompt?: string) - Refine outline based on user feedback
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
- generateSection(sectionId: string)
- generateAllSections()
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
- generateSEOMetadata(title?: string)
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
CRITICAL BEHAVIOR & USER GUIDANCE:
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
- When user asks to research something, call getResearchKeywords() first to collect their keywords
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
USER GUIDANCE STRATEGY:
- After research completion, ALWAYS guide user toward outline creation as the next step
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
- After outline generation, ALWAYS guide user to review and confirm the outline
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
- After content generation, guide users to review and confirm their content before moving to SEO stage
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after content confirmation, show SEO analysis and publishing suggestions
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
ENGAGEMENT TACTICS:
- DO NOT ask for clarification - take action immediately with the information provided
- Always call the appropriate tool instead of just talking about what you could do
- Be aware of the current state and reference research results when relevant
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
- Use encouraging language and highlight progress made
- If user seems lost, remind them of the current stage and suggest the next step
- When research is complete, emphasize the value of the data found and guide to outline creation
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
- Encourage users to make small manual edits to the outline UI before using AI for major changes
`;
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
research={research}
outline={outline}
outlineConfirmed={outlineConfirmed}
/>
{/* Outline Progress Modal */}
{/* Outline modal */}
<OutlineProgressModal
isVisible={showOutlineModal}
status={outlinePolling.currentStatus}
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
error={outlinePolling.error}
/>
{/* Medium generation / Rewrite modal */}
<OutlineProgressModal
isVisible={showModal}
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
latestMessage={rewritePolling.isPolling ?
(rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : '') :
(mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : '')
}
error={rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error}
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
<TaskProgressModals
showOutlineModal={showOutlineModal}
outlinePolling={outlinePolling}
showModal={showModal}
rewritePolling={rewritePolling}
mediumPolling={mediumPolling}
/>
{/* SEO Analysis Modal */}
<SEOAnalysisModal
isOpen={isSEOAnalysisModalOpen}
onClose={() => setIsSEOAnalysisModalOpen(false)}
onClose={handleSEOModalClose}
blogContent={buildFullMarkdown()}
blogTitle={selectedTitle}
researchData={research}
onApplyRecommendations={(recommendations) => {
console.log('Applying SEO recommendations:', recommendations);
// TODO: Implement recommendation application logic
}}
onApplyRecommendations={handleApplySeoRecommendations}
onAnalysisComplete={handleSEOAnalysisComplete}
/>
{/* SEO Metadata Modal */}
@@ -639,10 +712,14 @@ Available tools:
blogContent={buildFullMarkdown()}
blogTitle={selectedTitle}
researchData={research}
outline={outline}
seoAnalysis={seoAnalysis}
onMetadataGenerated={(metadata) => {
console.log('SEO metadata generated:', metadata);
setSeoMetadata(metadata);
// TODO: Implement metadata application logic
// Metadata is now saved and will be used when publishing to WordPress/Wix
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
// Publisher component will use this metadata when calling publish API
}}
/>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
interface BlogWriterLandingProps {
onStartWriting: () => void;
@@ -198,7 +199,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
</div>
</div>
{/* SuperPowers Modal */}
{/* SuperPowers Modal with 6 Phases */}
{showSuperPowers && (
<div style={{
position: 'fixed',
@@ -206,20 +207,18 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
backgroundColor: 'rgba(0, 0, 0, 0.95)',
display: 'flex',
alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 1000
zIndex: 1000,
overflowY: 'auto'
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '20px',
padding: '40px',
maxWidth: '900px',
width: '90%',
maxHeight: '80vh',
overflow: 'auto',
width: '100%',
maxWidth: '1400px',
minHeight: '100%',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
}}>
{/* Modal Header */}
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
</button>
</div>
{/* SuperPowers Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '24px'
}}>
{superPowers.map((power, index) => (
<div
key={index}
style={{
padding: '24px',
borderRadius: '16px',
border: '1px solid #e0e0e0',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
e.currentTarget.style.borderColor = '#1976d2';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '12px'
}}>
{/* 6 Phases Section */}
<BlogWriterPhasesSection />
{/* Quick SuperPowers Grid */}
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
<h2 style={{
margin: '0 0 20px 0',
fontSize: '1.5rem',
textAlign: 'center',
color: '#333'
}}>
Quick Feature Overview
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '20px'
}}>
{superPowers.map((power, index) => (
<div
key={index}
style={{
padding: '20px',
borderRadius: '12px',
border: '1px solid #e0e0e0',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
e.currentTarget.style.borderColor = '#1976d2';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
<div style={{
fontSize: '2rem',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
borderRadius: '12px'
gap: '16px',
marginBottom: '12px'
}}>
{power.icon}
<div style={{
fontSize: '2rem',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
borderRadius: '12px'
}}>
{power.icon}
</div>
<h3 style={{
margin: 0,
fontSize: '1.1rem',
color: '#333',
fontWeight: '600'
}}>
{power.title}
</h3>
</div>
<h3 style={{
<p style={{
margin: 0,
fontSize: '1.3rem',
color: '#333',
fontWeight: '600'
color: '#666',
lineHeight: '1.6',
fontSize: '0.9rem'
}}>
{power.title}
</h3>
{power.description}
</p>
</div>
<p style={{
margin: 0,
color: '#666',
lineHeight: '1.6',
fontSize: '1rem'
}}>
{power.description}
</p>
</div>
))}
))}
</div>
</div>
{/* Modal Footer */}

View File

@@ -0,0 +1,576 @@
import React, { useState } from 'react';
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
interface PhaseFeature {
title: string;
description: string;
details: string[];
imagePlaceholder: string;
}
interface BlogPhase {
id: string;
name: string;
icon: string;
shortDescription: string;
features: PhaseFeature[];
technicalDetails: {
aiModel: string;
promptType: string;
outputFormat: string;
integration: string;
};
videoPlaceholder: string;
}
const BlogWriterPhasesSection: React.FC = () => {
const [activePhase, setActivePhase] = useState<number | null>(null);
const phases: BlogPhase[] = [
{
id: 'research',
name: 'Research & Strategy',
icon: '🔍',
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
features: [
{
title: 'Google Search Grounding',
description: 'Real-time web research using Gemini\'s native Google Search integration',
details: [
'Single API call for comprehensive research',
'Live web data from credible sources',
'Automatic source extraction and citation',
'Current trends and 2024-2025 insights',
'Market analysis and forecasts'
],
imagePlaceholder: '/images/research-google-grounding.jpg'
},
{
title: 'Competitor Analysis',
description: 'Identify top players and content opportunities in your niche',
details: [
'Top competitor content analysis',
'Content gap identification',
'Unique angle discovery',
'Market positioning insights',
'Competitive advantage opportunities'
],
imagePlaceholder: '/images/research-competitor.jpg'
},
{
title: 'Keyword Intelligence',
description: 'Comprehensive keyword analysis with SEO opportunities',
details: [
'Primary, secondary, and long-tail keyword identification',
'Search volume and competition analysis',
'Keyword clustering and grouping',
'Content optimization suggestions',
'Target audience keyword mapping'
],
imagePlaceholder: '/images/research-keywords.jpg'
},
{
title: 'Content Angle Generation',
description: 'AI-generated compelling content angles for maximum engagement',
details: [
'5 unique content angle suggestions',
'Trending topic identification',
'Audience pain point mapping',
'Viral potential assessment',
'Expert opinion synthesis'
],
imagePlaceholder: '/images/research-angles.jpg'
}
],
technicalDetails: {
aiModel: 'Gemini Pro with Google Search Grounding',
promptType: 'Comprehensive research prompt',
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
integration: 'GeminiGroundedProvider via research_service.py'
},
videoPlaceholder: '/videos/phase1-research.mp4'
},
{
id: 'outline',
name: 'Intelligent Outline',
icon: '📝',
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
features: [
{
title: 'AI Outline Generation',
description: 'Comprehensive outline based on research with SEO optimization',
details: [
'Section-by-section breakdown',
'Subheadings and key points',
'Target word counts per section',
'Logical flow and progression',
'SEO-optimized structure'
],
imagePlaceholder: '/images/outline-generation.jpg'
},
{
title: 'Source Mapping & Grounding',
description: 'Connect each section to research sources with citations',
details: [
'Automatic source-to-section mapping',
'Grounding support scores',
'Citation suggestions',
'Source credibility ratings',
'Reference verification'
],
imagePlaceholder: '/images/outline-grounding.jpg'
},
{
title: 'Interactive Refinement',
description: 'Human-in-the-loop editing with AI assistance',
details: [
'Add, remove, merge sections',
'Reorder and restructure',
'AI enhancement suggestions',
'Custom instructions support',
'Multiple outline versions'
],
imagePlaceholder: '/images/outline-refine.jpg'
},
{
title: 'Title Generation',
description: 'Multiple SEO-optimized title options',
details: [
'AI-generated title variations',
'SEO score per title',
'Engagement potential analysis',
'Keyword integration',
'Click-through optimization'
],
imagePlaceholder: '/images/outline-titles.jpg'
}
],
technicalDetails: {
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
promptType: 'Structured outline prompt with research context',
outputFormat: 'JSON outline with sections, headings, key_points, references',
integration: 'OutlineService via parallel_processor.py'
},
videoPlaceholder: '/videos/phase2-outline.mp4'
},
{
id: 'content',
name: 'Content Generation',
icon: '✨',
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
features: [
{
title: 'Smart Content Generation',
description: 'AI-powered section writing with context awareness',
details: [
'Section-by-section generation',
'Context memory across sections',
'Smooth transitions between sections',
'Consistent tone and style',
'Natural keyword integration'
],
imagePlaceholder: '/images/content-generation.jpg'
},
{
title: 'Continuity Analysis',
description: 'Real-time flow and coherence monitoring',
details: [
'Narrative flow assessment',
'Coherence scoring',
'Transition quality analysis',
'Tone consistency tracking',
'Content quality metrics'
],
imagePlaceholder: '/images/content-continuity.jpg'
},
{
title: 'Source Integration',
description: 'Automatic citation and source reference',
details: [
'Relevant URL selection',
'Natural citation insertion',
'Source attribution',
'Evidence-backed content',
'Reference management'
],
imagePlaceholder: '/images/content-sources.jpg'
},
{
title: 'Medium Blog Mode',
description: 'Quick generation for Medium-style articles',
details: [
'Single-call full blog generation',
'Medium-optimized formatting',
'Engagement-focused structure',
'SEO-ready output',
'Fast turnaround option'
],
imagePlaceholder: '/images/content-medium.jpg'
}
],
technicalDetails: {
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
promptType: 'Context-aware section prompt with research',
outputFormat: 'Markdown content with transitions and metrics',
integration: 'EnhancedContentGenerator with ContextMemory'
},
videoPlaceholder: '/videos/phase3-content.mp4'
},
{
id: 'seo',
name: 'SEO Analysis',
icon: '📈',
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
features: [
{
title: 'Comprehensive SEO Scoring',
description: 'Multi-dimensional SEO analysis across key factors',
details: [
'Overall SEO score (0-100)',
'Structure optimization score',
'Keyword optimization rating',
'Readability assessment',
'Quality metrics evaluation'
],
imagePlaceholder: '/images/seo-scoring.jpg'
},
{
title: 'Actionable Recommendations',
description: 'AI-powered improvement suggestions',
details: [
'Priority-ranked fixes',
'Specific text improvements',
'Keyword density optimization',
'Heading structure suggestions',
'Content enhancement ideas'
],
imagePlaceholder: '/images/seo-recommendations.jpg'
},
{
title: 'AI-Powered Content Refinement',
description: 'Automatically apply SEO recommendations',
details: [
'Smart content rewriting',
'Preserves original intent',
'Natural keyword integration',
'Readability improvement',
'Structure optimization'
],
imagePlaceholder: '/images/seo-apply.jpg'
},
{
title: 'Keyword Analysis',
description: 'Deep dive into keyword performance',
details: [
'Primary keyword density',
'Semantic keyword usage',
'Long-tail keyword opportunities',
'Keyword distribution heatmap',
'Optimization recommendations'
],
imagePlaceholder: '/images/seo-keywords.jpg'
}
],
technicalDetails: {
aiModel: 'Parallel non-AI analyzers + single AI call',
promptType: 'Structured SEO analysis prompt',
outputFormat: 'Comprehensive SEO report with scores and recommendations',
integration: 'BlogContentSEOAnalyzer with parallel processing'
},
videoPlaceholder: '/videos/phase4-seo.mp4'
},
{
id: 'metadata',
name: 'SEO Metadata',
icon: '🎯',
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
features: [
{
title: 'Comprehensive Metadata',
description: 'All-in-one SEO metadata generation',
details: [
'SEO-optimized title (50-60 chars)',
'Meta description with CTA',
'URL slug optimization',
'Blog tags and categories',
'Social hashtags'
],
imagePlaceholder: '/images/metadata-comprehensive.jpg'
},
{
title: 'Open Graph & Twitter Cards',
description: 'Rich social media previews',
details: [
'OG title and description',
'Twitter card optimization',
'Image preview settings',
'Social engagement boost',
'Click-through optimization'
],
imagePlaceholder: '/images/metadata-social.jpg'
},
{
title: 'Structured Data',
description: 'Schema.org markup for rich snippets',
details: [
'Article schema',
'Organization markup',
'Breadcrumb schema',
'FAQ schema support',
'Enhanced search results'
],
imagePlaceholder: '/images/metadata-schema.jpg'
},
{
title: 'Multi-Format Output',
description: 'Ready-to-use metadata in all formats',
details: [
'HTML meta tags',
'JSON-LD structured data',
'WordPress export format',
'Wix integration format',
'One-click copy options'
],
imagePlaceholder: '/images/metadata-export.jpg'
}
],
technicalDetails: {
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
promptType: 'Personalized metadata prompt with context',
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
integration: 'BlogSEOMetadataGenerator with optimization'
},
videoPlaceholder: '/videos/phase5-metadata.mp4'
},
{
id: 'publish',
name: 'Publish & Distribute',
icon: '🚀',
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
features: [
{
title: 'Multi-Platform Publishing',
description: 'Publish to multiple platforms simultaneously',
details: [
'WordPress direct publishing',
'Wix blog integration',
'Medium publishing',
'Custom blog platforms',
'API integrations'
],
imagePlaceholder: '/images/publish-platforms.jpg'
},
{
title: 'Content Scheduling',
description: 'Schedule posts for optimal timing',
details: [
'Time-based scheduling',
'Timezone management',
'Bulk scheduling support',
'Calendar integration',
'Reminder notifications'
],
imagePlaceholder: '/images/publish-schedule.jpg'
},
{
title: 'Revision Management',
description: 'Track and manage content versions',
details: [
'Version history',
'Change tracking',
'Rollback capabilities',
'A/B testing support',
'Performance comparison'
],
imagePlaceholder: '/images/publish-versions.jpg'
},
{
title: 'Analytics Integration',
description: 'Post-publish performance tracking',
details: [
'View count tracking',
'Engagement metrics',
'SEO performance',
'Traffic analysis',
'Conversion tracking'
],
imagePlaceholder: '/images/publish-analytics.jpg'
}
],
technicalDetails: {
aiModel: 'Platform-specific API integrations',
promptType: 'N/A - publishing only',
outputFormat: 'Published content with URL',
integration: 'Platform APIs via Publisher component'
},
videoPlaceholder: '/videos/phase6-publish.mp4'
}
];
return (
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
<Container maxWidth="lg">
{/* Section Title */}
<Box sx={{ textAlign: 'center', mb: 6 }}>
<Typography
variant="h2"
component="h2"
sx={{
fontSize: { xs: '2rem', md: '3rem' },
fontWeight: 700,
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 2
}}
>
Complete AI Blog Writing Workflow
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
</Typography>
</Box>
{/* Phase Cards */}
<Grid container spacing={4}>
{phases.map((phase, index) => (
<Grid item xs={12} md={6} key={phase.id}>
<Card
sx={{
height: '100%',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: activePhase === index ? 2 : 1,
borderColor: activePhase === index ? 'primary.main' : 'divider',
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: 6,
}
}}
onClick={() => setActivePhase(activePhase === index ? null : index)}
>
<CardContent sx={{ p: 3 }}>
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
{phase.icon}
</Typography>
<Box flex={1}>
<Typography variant="h5" fontWeight={600} gutterBottom>
{phase.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{phase.shortDescription}
</Typography>
</Box>
<Chip
label={`Phase ${index + 1}`}
size="small"
color="primary"
variant="outlined"
/>
</Stack>
{activePhase === index && (
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
{/* Video Placeholder */}
<Box
sx={{
width: '100%',
aspectRatio: '16/9',
bgcolor: 'grey.200',
borderRadius: 2,
mb: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="body2" color="text.secondary">
🎥 Video: {phase.videoPlaceholder}
</Typography>
</Box>
{/* Features Grid */}
<Grid container spacing={2} mb={3}>
{phase.features.map((feature, idx) => (
<Grid item xs={12} sm={6} key={idx}>
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
<Box
sx={{
width: '100%',
aspectRatio: '4/3',
bgcolor: 'grey.100',
borderRadius: 1,
mb: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="caption" color="text.secondary">
📷 Image
</Typography>
</Box>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
{feature.title}
</Typography>
<Typography variant="body2" color="text.secondary" mb={1}>
{feature.description}
</Typography>
<Stack spacing={0.5}>
{feature.details.slice(0, 3).map((detail, i) => (
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
<Typography variant="caption" color="text.secondary">
{detail}
</Typography>
</Stack>
))}
</Stack>
</Card>
</Grid>
))}
</Grid>
{/* Technical Details */}
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesome sx={{ fontSize: 18 }} />
Technical Implementation
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="caption" fontWeight={600}>Integration</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{phase.technicalDetails.integration}
</Typography>
</Grid>
</Grid>
</Card>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Container>
</Box>
);
};
export default BlogWriterPhasesSection;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PhaseNavigation from '../PhaseNavigation';
interface HeaderBarProps {
phases: any[];
currentPhase: string;
onPhaseClick: (phaseId: string) => void;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPhaseClick }) => {
return (
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: 'bold',
color: '#666'
}}>
A
</div>
</div>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
/>
</div>
);
};
export default HeaderBar;

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface OutlineCtaBannerProps {
onGenerate: () => void;
}
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
return (
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
<button
onClick={onGenerate}
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
>
Next: Create Outline
</button>
</div>
);
};
export default OutlineCtaBanner;

View File

@@ -0,0 +1,185 @@
import React from 'react';
import ResearchResults from '../ResearchResults';
import EnhancedTitleSelector from '../EnhancedTitleSelector';
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
import { BlogEditor } from '../WYSIWYG';
import OutlineCtaBanner from './OutlineCtaBanner';
interface PhaseContentProps {
currentPhase: string;
research: any;
outline: any[];
outlineConfirmed: boolean;
titleOptions: any[];
selectedTitle?: string | null;
researchTitles: any[];
aiGeneratedTitles: any[];
sourceMappingStats: any;
groundingInsights: any;
optimizationResults: any;
researchCoverage: any;
setOutline: (o: any) => void;
sections: Record<string, string>;
handleContentUpdate: any;
handleContentSave: any;
continuityRefresh: number | null;
flowAnalysisResults: any;
outlineGenRef: React.RefObject<any>;
blogWriterApi: any;
contentConfirmed: boolean;
seoAnalysis: any;
seoMetadata: any;
onTitleSelect: any;
onCustomTitle: any;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
currentPhase,
research,
outline,
outlineConfirmed,
titleOptions,
selectedTitle,
researchTitles,
aiGeneratedTitles,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
setOutline,
sections,
handleContentUpdate,
handleContentSave,
continuityRefresh,
flowAnalysisResults,
outlineGenRef,
blogWriterApi,
contentConfirmed,
seoAnalysis,
seoMetadata,
onTitleSelect,
onCustomTitle
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
{currentPhase === 'research' && (
<>
{research ? (
<ResearchResults research={research} />
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Start Your Research</h3>
<p>Use the copilot to begin researching your blog topic.</p>
</div>
)}
</>
)}
{currentPhase === 'outline' && research && (
<>
{outline.length === 0 && (
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
)}
{outline.length > 0 ? (
<>
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle || undefined}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={onTitleSelect}
onCustomTitle={onCustomTitle}
/>
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
</>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Create Your Outline</h3>
<p>Use the copilot to generate an outline based on your research.</p>
</div>
)}
</>
)}
{currentPhase === 'content' && outline.length > 0 && (
<>
{outlineConfirmed ? (
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Confirm Your Outline</h3>
<p>Review and confirm your outline before generating content.</p>
</div>
)}
</>
)}
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
<>
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
titleOptions={titleOptions}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
continuityRefresh={continuityRefresh || undefined}
flowAnalysisResults={flowAnalysisResults}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Loading Content...</h3>
<p>Please wait while your content is being optimized.</p>
</div>
)}
</>
)}
{/* Fallback for SEO phase if conditions not met */}
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Optimize your blog for search engines.</h3>
<p>Complete the content phase first to enable SEO optimization.</p>
</div>
)}
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
<div style={{ padding: '20px' }}>
<h3>Publish Your Blog</h3>
<p>Your blog is ready to publish!</p>
</div>
)}
</div>
</div>
);
};
export default PhaseContent;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { OutlineProgressModal } from '../OutlineProgressModal';
interface PollingState {
isPolling: boolean;
currentStatus: string;
progressMessages: { message: string }[];
error?: string | null;
}
interface TaskProgressModalsProps {
showOutlineModal: boolean;
outlinePolling: PollingState;
showModal: boolean;
rewritePolling: PollingState;
mediumPolling: PollingState;
}
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
showOutlineModal,
outlinePolling,
showModal,
rewritePolling,
mediumPolling,
}) => {
return (
<>
<OutlineProgressModal
isVisible={showOutlineModal}
status={outlinePolling.currentStatus}
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
error={outlinePolling.error ?? null}
/>
<OutlineProgressModal
isVisible={showModal}
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
latestMessage={rewritePolling.isPolling ? (
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
) : (
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
)}
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
/>
</>
);
};
export default TaskProgressModals;

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { CopilotSidebar } from '@copilotkit/react-ui';
import '@copilotkit/react-ui/styles.css';
interface WriterCopilotSidebarProps {
suggestions: any[];
research: any;
outline: any[];
outlineConfirmed: boolean;
}
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
suggestions,
research,
outline,
outlineConfirmed,
}) => {
return (
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
}}
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch
? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
angles: research.suggested_angles?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational',
}
: null;
const outlineContext = hasOutline
? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map((s: any) => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum: number, s: any) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline
.map(
(s: any) => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`
)
.join('; ')}
`
: '';
const toolGuide = `
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
CURRENT STATE:
${hasResearch && researchInfo ? `
✅ RESEARCH COMPLETED:
- Found ${researchInfo.sources} sources with Google Search grounding
- Generated ${researchInfo.queries} search queries
- Created ${researchInfo.angles} content angles
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:
- getResearchKeywords(prompt?: string) - Get keywords from user for research
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
- researchTopic(keywords: string, industry?: string, target_audience?: string)
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
- generateOutline()
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
- refineOutline(prompt?: string) - Refine outline based on user feedback
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
- generateSection(sectionId: string)
- generateAllSections()
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
- generateSEOMetadata(title?: string)
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
CRITICAL BEHAVIOR & USER GUIDANCE:
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
- When user asks to research something, call getResearchKeywords() first to collect their keywords
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
USER GUIDANCE STRATEGY:
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
- After research completion, ALWAYS guide user toward outline creation as the next step
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
- After outline generation, ALWAYS guide user to review and confirm the outline
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
- After content generation, guide users to review and confirm their content before moving to SEO stage
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
- Only after content confirmation, show SEO analysis and publishing suggestions
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
ENGAGEMENT TACTICS:
- DO NOT ask for clarification - take action immediately with the information provided
- Always call the appropriate tool instead of just talking about what you could do
- Be aware of the current state and reference research results when relevant
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
- Use encouraging language and highlight progress made
- If user seems lost, remind them of the current stage and suggest the next step
- When research is complete, emphasize the value of the data found and guide to outline creation
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
- Encourage users to make small manual edits to the outline UI before using AI for major changes
`;
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
/>
);
};
export default WriterCopilotSidebar;

View File

@@ -0,0 +1,90 @@
import { useRef } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { debug } from '../../../utils/debug';
type ConfirmCb = () => string | Promise<string>;
type AnalyzeCb = () => string | Promise<string>;
type OpenMetadataCb = () => void;
interface UseBlogWriterCopilotActionsParams {
isSEOAnalysisModalOpen: boolean;
lastSEOModalOpenRef: React.MutableRefObject<number>;
runSEOAnalysisDirect: AnalyzeCb;
confirmBlogContent: ConfirmCb;
sections: Record<string, string>;
research: any;
openSEOMetadata: OpenMetadataCb;
}
// Consolidates all Copilot actions used by BlogWriter
export function useBlogWriterCopilotActions({
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent,
sections,
research,
openSEOMetadata,
}: UseBlogWriterCopilotActionsParams) {
// Maintain the same any-cast pattern for parity with component
const useCopilotActionTyped = useCopilotAction as any;
// confirmBlogContent
useCopilotActionTyped({
name: 'confirmBlogContent',
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
parameters: [],
handler: async () => {
const msg = await confirmBlogContent();
return msg;
},
});
// analyzeSEO
useCopilotActionTyped({
name: 'analyzeSEO',
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
parameters: [],
handler: async () => {
debug.log('[BlogWriter] SEO analysis action', {
modalOpen: isSEOAnalysisModalOpen,
hasSections: !!sections && Object.keys(sections).length > 0,
hasResearch: !!research && !!(research as any)?.keyword_analysis,
});
const now = Date.now();
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
return 'SEO analysis is already open.';
}
const msg = await runSEOAnalysisDirect();
return msg;
},
});
// generateSEOMetadata
useCopilotActionTyped({
name: 'generateSEOMetadata',
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
parameters: [
{
name: 'title',
type: 'string',
description: 'Optional blog title to use for metadata generation',
required: false,
},
],
handler: async ({ title }: { title?: string }) => {
if (!sections || Object.keys(sections).length === 0) {
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
}
if (!research || !research.keyword_analysis) {
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
}
openSEOMetadata();
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
},
});
}
export default useBlogWriterCopilotActions;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { blogWriterApi } from '../../services/blogWriterApi';
import { debug } from '../../utils/debug';
interface Props {
sectionId: string;
@@ -17,36 +18,27 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disa
// If we have flow analysis results, use them instead of API call
if (flowAnalysisResults && flowAnalysisResults.sections) {
console.log('🔍 [ContinuityBadge] Flow analysis results available:', flowAnalysisResults);
console.log('🔍 [ContinuityBadge] Looking for section ID:', sectionId);
console.log('🔍 [ContinuityBadge] Available section IDs:', flowAnalysisResults.sections.map((s: any) => s.section_id));
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
if (sectionAnalysis) {
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
if (mounted) {
setMetrics({
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
flow: sectionAnalysis.flow_score,
consistency: sectionAnalysis.consistency_score,
progression: sectionAnalysis.progression_score
});
}
return;
} else {
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
}
}
// Fallback to API call if no flow analysis results
console.log('🔍 [ContinuityBadge] Fetching continuity for section:', sectionId);
debug.log('[ContinuityBadge] fetching', { sectionId });
blogWriterApi.getContinuity(sectionId)
.then(res => {
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
if (mounted) setMetrics(res.continuity_metrics || null);
})
.catch((error) => {
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
/* ignore */
debug.error('[ContinuityBadge] fetch error', error);
});
return () => { mounted = false; };
}, [sectionId, refreshToken, flowAnalysisResults]);

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
interface Props {
outline: BlogOutlineSection[];
@@ -24,7 +25,10 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
}) => {
const [editingSection, setEditingSection] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
const [showAddSection, setShowAddSection] = useState(false);
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
const [newSectionData, setNewSectionData] = useState({
heading: '',
subheadings: '',
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
border: '1px solid #e0e0e0',
overflow: 'hidden'
}}>
{imageModalState.open && (
<ImageGeneratorModal
isOpen={imageModalState.open}
onClose={() => setImageModalState({ open: false })}
defaultPrompt={(() => {
const sec = outline.find(s => s.id === imageModalState.sectionId);
return sec?.heading || '';
})()}
context={(() => {
const sec = outline.find(s => s.id === imageModalState.sectionId);
return {
title: sec?.heading,
section: sec,
outline,
research,
sectionId: imageModalState.sectionId
};
})()}
onImageGenerated={(imageBase64, sectionId) => {
if (sectionId) {
setSectionImages(prev => ({ ...prev, [sectionId]: imageBase64 }));
}
}}
/>
)}
{/* Header */}
<div style={{
padding: '20px',
@@ -275,12 +304,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
{/* Section Header */}
<div style={{
padding: '16px 20px',
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
justifyContent: 'space-between',
transition: 'background-color 0.2s ease'
}}
onMouseEnter={() => setHoveredSection(section.id)}
onMouseLeave={() => setHoveredSection(null)}
onClick={() => toggleExpanded(section.id)}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<div style={{
@@ -375,6 +407,24 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setImageModalState({ open: true, sectionId: section.id });
}}
title="Generate Image"
style={{
backgroundColor: '#1976d2',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
fontSize: '12px',
color: '#fff'
}}
>
🖼 Generate Image
</button>
<button
onClick={(e) => {
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
{/* Expanded Section Content */}
{expandedSections.has(section.id) && (
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
{/* Subheadings */}
{section.subheadings && section.subheadings.length > 0 && (
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
</div>
)}
{/* Generated Image Display */}
{sectionImages[section.id] && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
🖼 Generated Image
</h4>
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
maxWidth: '600px',
backgroundColor: 'white'
}}>
<img
src={`data:image/png;base64,${sectionImages[section.id]}`}
alt={`Generated image for ${section.heading}`}
style={{
width: '100%',
height: 'auto',
display: 'block'
}}
/>
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<button
onClick={(e) => {
e.stopPropagation();
setImageModalState({ open: true, sectionId: section.id });
}}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500
}}
>
Generate Image for this section
</button>
</div>
</div>
)}
</div>

View File

@@ -12,269 +12,11 @@ interface KeywordInputFormProps {
onTaskStart?: (taskId: string) => void;
}
// Separate component to manage form state
const ResearchForm: React.FC<{
prompt?: string;
onSubmit: (data: { keywords: string; blogLength: string }) => void;
onCancel: () => void;
}> = ({ prompt, onSubmit, onCancel }) => {
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
const hasValidInput = keywords.trim().length > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasValidInput) {
onSubmit({ keywords: keywords.trim(), blogLength });
} else {
window.alert('Please enter keywords or a topic to start research.');
}
};
return (
<form
onSubmit={handleSubmit}
style={{
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '12px',
border: '1px solid #e0e0e0',
margin: '8px 0'
}}
>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
🔍 Let's Research Your Blog Topic
</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
{prompt || 'Please provide the keywords or topic you want to research for your blog:'}
</p>
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
Keywords or Topic *
</label>
<input
type="text"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
onFocus={(e) => e.target.select()}
placeholder="e.g., artificial intelligence, machine learning, AI trends"
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #1976d2',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box'
}}
autoFocus
autoComplete="off"
spellCheck="false"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
Blog Length (words)
</label>
<select
value={blogLength}
onChange={(e) => setBlogLength(e.target.value)}
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #1976d2',
borderRadius: '6px',
fontSize: '14px',
outline: 'none',
backgroundColor: 'white',
boxSizing: 'border-box'
}}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
<option value="1500">1500 words (Long blog)</option>
<option value="2000">2000+ words (Comprehensive guide)</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
<button
type="submit"
disabled={!hasValidInput}
style={{
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '10px 20px',
cursor: hasValidInput ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500',
flex: 1
}}
>
🚀 Start Research {hasValidInput ? '(Enabled)' : '(Disabled)'}
</button>
<button
type="button"
onClick={onCancel}
style={{
backgroundColor: 'transparent',
border: '1px solid #ddd',
borderRadius: '6px',
padding: '10px 20px',
cursor: 'pointer',
fontSize: '14px',
color: '#666'
}}
>
Cancel
</button>
</div>
</form>
);
};
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// Keyword input action with Human-in-the-Loop
useCopilotActionTyped({
name: 'getResearchKeywords',
description: 'Get keywords from user for blog research',
parameters: [
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
],
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
if (status === 'complete') {
return (
<div style={{
padding: '16px',
backgroundColor: '#f0f8ff',
borderRadius: '8px',
border: '1px solid #1976d2'
}}>
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
✅ Research keywords received! Starting research...
</p>
</div>
);
}
return (
<ResearchForm
prompt={args.prompt}
onSubmit={(formData) => {
onKeywordsReceived?.(formData);
respond?.(JSON.stringify(formData));
}}
onCancel={() => respond?.('CANCEL')}
/>
);
}
});
// Research action that actually performs the research
useCopilotActionTyped({
name: 'performResearch',
description: 'Perform research with collected keywords and blog length',
parameters: [
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
],
handler: async ({ formData }: { formData: string }) => {
try {
const data = JSON.parse(formData);
const { keywords, blogLength } = data;
const keywordList = keywords.includes(',')
? keywords.split(',').map((k: string) => k.trim())
: [keywords.trim()]; // Preserve single phrases as-is
// Check frontend cache first
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
console.log('Frontend cache hit - returning cached result instantly');
onResearchComplete?.(cachedResult);
return {
success: true,
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
cached: true
};
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
// Store the blog length in localStorage for later use
localStorage.setItem('blog_length_target', blogLength);
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
onTaskStart?.(task_id); // Notify parent component to start polling
return {
success: true,
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
task_id: task_id
};
} catch (error) {
console.error(`Research failed: ${error}`);
return {
success: false,
message: `❌ Research failed: ${error}. Please try again with different keywords.`
};
}
},
render: ({ status }: any) => {
if (status === 'inProgress' || status === 'executing') {
return (
<div style={{
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e0e0e0',
margin: '8px 0'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid #1976d2',
borderTop: '2px solid transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
</div>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
return null;
}
});
// This component now only provides polling functionality
// The keyword input form is handled by ResearchAction component
return (
<>
@@ -294,4 +36,4 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
);
};
export default KeywordInputForm;
export default KeywordInputForm;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
@@ -11,18 +11,38 @@ interface OutlineGeneratorProps {
const useCopilotActionTyped = useCopilotAction as any;
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
research,
onTaskStart,
onPollingStart,
onModalShow
}) => {
}, ref) => {
// Expose an imperative method to trigger outline generation directly (bypass LLM)
useImperativeHandle(ref, () => ({
generateNow: async () => {
if (!research) {
return { success: false, message: 'No research yet. Please research a topic first.' };
}
try {
onModalShow?.();
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
onTaskStart(task_id);
onPollingStart(task_id);
return { success: true, task_id };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, message: errorMessage };
}
}
}));
useCopilotActionTyped({
name: 'generateOutline',
description: 'Generate outline from research results using AI analysis',
parameters: [],
handler: async () => {
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
if (!research) {
return { success: false, message: 'No research yet. Please research a topic first.' };
}
try {
// Show progress modal immediately when user clicks "Create outline"
@@ -64,7 +84,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
}
},
render: ({ status }: any) => {
console.log('generateOutline render called with status:', status);
if (status === 'inProgress' || status === 'executing') {
return (
<div style={{
@@ -105,6 +124,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
});
return null; // This component only provides the copilot action
};
});
export default OutlineGenerator;

View File

@@ -0,0 +1,89 @@
import React from 'react';
export interface Phase {
id: string;
name: string;
icon: string;
description: string;
completed: boolean;
current: boolean;
disabled: boolean;
}
interface PhaseNavigationProps {
phases: Phase[];
onPhaseClick: (phaseId: string) => void;
currentPhase: string;
}
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
onPhaseClick,
currentPhase
}) => {
return (
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
padding: '8px 0',
flexWrap: 'wrap'
}}>
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
return (
<button
key={phase.id}
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '20px',
border: 'none',
fontSize: '14px',
fontWeight: '500',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isCurrent
? '#1976d2'
: isCompleted
? '#4caf50'
: isDisabled
? '#f5f5f5'
: '#e3f2fd',
color: isCurrent
? 'white'
: isCompleted
? 'white'
: isDisabled
? '#999'
: '#1976d2',
opacity: isDisabled ? 0.6 : 1,
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
transform: isCurrent ? 'translateY(-1px)' : 'none'
}}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '16px' }}>
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
</span>
)}
</button>
);
})}
</div>
);
};
export default PhaseNavigation;

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import PhaseNavigation from './PhaseNavigation';
import { Phase } from './PhaseNavigation';
// Test component to verify phase navigation functionality
export const PhaseNavigationTest: React.FC = () => {
const [currentPhase, setCurrentPhase] = useState<string>('research');
const testPhases: Phase[] = [
{
id: 'research',
name: 'Research',
icon: '🔍',
description: 'Research your topic and gather data',
completed: true,
current: currentPhase === 'research',
disabled: false
},
{
id: 'outline',
name: 'Outline',
icon: '📝',
description: 'Create and refine your blog outline',
completed: true,
current: currentPhase === 'outline',
disabled: false
},
{
id: 'content',
name: 'Content',
icon: '✍️',
description: 'Generate and edit your blog content',
completed: false,
current: currentPhase === 'content',
disabled: false
},
{
id: 'seo',
name: 'SEO',
icon: '📈',
description: 'Optimize for search engines',
completed: false,
current: currentPhase === 'seo',
disabled: true
},
{
id: 'publish',
name: 'Publish',
icon: '🚀',
description: 'Publish your blog post',
completed: false,
current: currentPhase === 'publish',
disabled: true
}
];
const handlePhaseClick = (phaseId: string) => {
setCurrentPhase(phaseId);
};
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h2>Phase Navigation Test</h2>
<p>Current Phase: <strong>{currentPhase}</strong></p>
<PhaseNavigation
phases={testPhases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
/>
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
<h3>Phase Status:</h3>
<ul>
{testPhases.map(phase => (
<li key={phase.id}>
<strong>{phase.name}</strong>:
{phase.completed ? ' ✅ Completed' : ' ⏳ Pending'} |
{phase.current ? ' 🎯 Current' : ''} |
{phase.disabled ? ' 🚫 Disabled' : ' ✅ Enabled'}
</li>
))}
</ul>
</div>
</div>
);
};
export default PhaseNavigationTest;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchPolling } from '../../hooks/usePolling';
@@ -15,13 +15,18 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [forceUpdate, setForceUpdate] = useState<number>(0);
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
// Cache the result for future use
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
@@ -35,84 +40,170 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
}
});
useCopilotActionTyped({
name: 'showResearchForm',
description: 'Show keyword input form for blog research',
parameters: [],
handler: async () => ({
success: true,
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
showForm: true
}),
render: ({ status }: any) => {
const _ = forceUpdate;
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (polling.currentStatus === 'in_progress' || polling.currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
return (
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
<input
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
<option value="1500">1500 words (Long blog)</option>
<option value="2000">2000 words (Comprehensive blog)</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setForceUpdate(prev => prev + 1);
return;
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
setForceUpdate(prev => prev + 1);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
>
🚀 Start Research
</button>
</div>
</div>
);
}
});
// Additional action to catch the specific suggestion message
useCopilotActionTyped({
name: 'researchTopic',
description: 'Research topic with keywords and persona context using Google Search grounding',
parameters: [
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: false },
{ name: 'industry', type: 'string', description: 'Industry', required: false },
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
],
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
try {
// If keywords is a topic description, preserve as single phrase unless comma-separated
const keywordList = keywords.includes(',')
? keywords.split(',').map(k => k.trim())
: [keywords.trim()]; // Preserve single phrases as-is
const industryValue = industry || 'General';
const audienceValue = target_audience || 'General';
// Check frontend cache first
const cachedResult = researchCache.getCachedResult(keywordList, industryValue, audienceValue);
if (cachedResult) {
console.log('Frontend cache hit - returning cached result instantly');
onResearchComplete?.(cachedResult);
return {
success: true,
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
cached: true
};
const trimmed = keywords.trim();
if (!trimmed) {
return "Please provide keywords or a topic for research.";
}
const keywordList = trimmed.includes(',')
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
: [trimmed];
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: industryValue,
target_audience: audienceValue,
word_count_target: blogLength ? parseInt(blogLength) : 1000
industry,
target_audience,
word_count_target: parseInt(blogLength)
};
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
return {
success: true,
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
task_id: task_id
};
return "Starting research with your provided keywords.";
} catch (error) {
console.error(`Research failed: ${error}`);
return {
success: false,
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
};
console.error('Failed to start research:', error);
return "Failed to start research. Please try again.";
}
},
render: () => null
}
});
return (
<ResearchProgressModal
open={showProgressModal}
title="Research in progress"
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => setShowProgressModal(false)}
/>
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => setShowProgressModal(false)}
/>
)}
</>
);
};

View File

@@ -3,6 +3,7 @@ import { useResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { researchCache } from '../../services/researchCache';
import { debug } from '../../utils/debug';
interface ResearchPollingHandlerProps {
taskId: string | null;
@@ -19,11 +20,11 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
const polling = useResearchPolling({
onProgress: (message) => {
console.log('ResearchPollingHandler - Progress message received:', message);
debug.log('[ResearchPollingHandler] progress', { message });
setCurrentMessage(message);
},
onComplete: (result) => {
console.log('ResearchPollingHandler - Research completed:', result);
debug.log('[ResearchPollingHandler] complete');
// Cache the result for future use
if (result && result.keywords) {
@@ -39,7 +40,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
setCurrentMessage('');
},
onError: (error) => {
console.error('Research polling error:', error);
debug.error('[ResearchPollingHandler] error', error);
onError?.(error);
setCurrentMessage('');
}
@@ -61,14 +62,14 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
};
}, [polling]);
console.log('ResearchPollingHandler render:', {
taskId,
isPolling: polling.isPolling,
status: polling.currentStatus,
progressMessages: polling.progressMessages?.length,
currentMessage,
error: polling.error
});
// Only log on meaningful changes
useEffect(() => {
debug.log('[ResearchPollingHandler] state', {
isPolling: polling.isPolling,
status: polling.currentStatus,
progressCount: polling.progressMessages?.length || 0
});
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
// Render the unified research progress modal when a task is present
return (

View File

@@ -1,6 +1,6 @@
/**
* Keyword Analysis Component
*
*
* Displays comprehensive keyword analysis including keyword types, densities,
* missing keywords, over-optimization, and distribution analysis.
*/
@@ -15,7 +15,7 @@ import {
IconButton,
Tooltip
} from '@mui/material';
import {
import {
GpsFixed,
Search,
Warning
@@ -36,86 +36,140 @@ interface KeywordAnalysisProps {
};
}
const baseCardSx = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const subCard = (color: string) => ({
p: 2,
borderRadius: 2,
border: `1px solid ${color}`,
background: `linear-gradient(145deg, ${color}14, ${color}1f)`
});
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
const keywordData = detailedAnalysis?.keyword_analysis;
const renderDensityRow = (keyword: string, density: number) => {
const status = density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal';
const chipColor = density > 3 ? 'error' : density < 1 ? 'warning' : 'success';
return (
<Box
key={keyword}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
borderRadius: 2,
backgroundColor: '#f1f5f9'
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
{keyword}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{status}
</Typography>
<Chip label={`${density.toFixed(1)}%`} color={chipColor} size="small" sx={{ fontWeight: 600 }} />
</Box>
</Box>
);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GpsFixed sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Keyword Analysis
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Keyword Types Overview */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCardSx}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
Keyword Types Found
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
<Box sx={subCard('rgba(34,197,94,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
Primary Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.primary_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
<Box sx={subCard('rgba(59,130,246,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
Long-tail Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.long_tail_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ fontWeight: 600, borderColor: '#93c5fd', color: '#1d4ed8' }} />
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
<Box sx={subCard('rgba(168,85,247,0.5)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
Semantic Keywords
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{keywordData?.semantic_keywords?.length || 0} found
</Typography>
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 0.5 }} />
))}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" color="secondary" size="small" sx={{ fontWeight: 600, borderColor: '#d8b4fe' }} />
))}
</Box>
</Box>
</Grid>
</Grid>
</Paper>
{/* Keyword Densities */}
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
Keyword Densities
</Typography>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Keyword Density Analysis
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Shows how frequently each keyword appears in your content as a percentage of total words.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Optimal Range:</strong> 1-3% for primary keywords
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Too Low (&lt;1%):</strong> Keyword may not be prominent enough
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Too High (&gt;3%):</strong> Risk of keyword stuffing
</Typography>
</Box>
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalys
arrow
>
<IconButton size="small" sx={{ color: 'primary.main' }}>
<Search />
<Search fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
<Box key={keyword} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)' }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{keyword}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
</Typography>
<Chip
label={`${density.toFixed(1)}%`}
color={density > 3 ? 'error' : density < 1 ? 'warning' : 'success'}
size="small"
/>
</Box>
</Box>
))
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
) : (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
No keyword density data available. Make sure your research data includes target keywords.
</Typography>
)}
</Box>
</Paper>
{/* Missing Keywords */}
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'error.main' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#dc2626' }}>
Missing Keywords
</Typography>
<Tooltip
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
arrow
>
<IconButton size="small" sx={{ color: 'error.main' }}>
<Warning />
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
<IconButton size="small" sx={{ color: '#dc2626' }}>
<Warning fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
{keywordData.missing_keywords.map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
))}
</Box>
</Paper>
)}
{/* Over-Optimized Keywords */}
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
<Paper sx={baseCardSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'warning.main' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#d97706' }}>
Over-Optimized Keywords
</Typography>
<Tooltip
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
arrow
>
<IconButton size="small" sx={{ color: 'warning.main' }}>
<Warning />
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
<IconButton size="small" sx={{ color: '#d97706' }}>
<Warning fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
{keywordData.over_optimization.map((keyword) => (
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
))}
</Box>
</Paper>
)}
{/* Keyword Distribution Analysis */}
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
<Paper sx={baseCardSx}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
Keyword Distribution Analysis
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
"{keyword}"
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
<Box
key={keyword}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc'
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
{keyword}
</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Density: {data.density?.toFixed(1)}%
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
In Headings: {data.in_headings ? 'Yes' : 'No'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
First Occurrence: Character {data.first_occurrence || 'Not found'}
</Typography>
</Grid>

View File

@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
return `${current}/${max}`;
};
// Consistent text input styling for better contrast
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#dadce0'
}
} as const;
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Core SEO Metadata
</Typography>
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
<Grid container spacing={3}>
{/* SEO Title */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
SEO Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
value={metadata.seo_title || ''}
onChange={handleTextFieldChange('seo_title')}
placeholder="Enter SEO-optimized title (50-60 characters)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
)
}}
/>
<Alert severity="info" sx={{ mt: 1 }}>
Include your primary keyword and make it compelling for clicks
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Include your primary keyword and keep between 5060 characters
</Typography>
</Paper>
</Grid>
{/* Meta Description */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Meta Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
value={metadata.meta_description || ''}
onChange={handleTextFieldChange('meta_description')}
placeholder="Enter compelling meta description (150-160 characters)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
)
}}
/>
<Alert severity="info" sx={{ mt: 1 }}>
Include a call-to-action and your primary keyword
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Aim for 150160 characters with a clear value proposition
</Typography>
</Paper>
</Grid>
{/* URL Slug */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<LinkIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
URL Slug
</Typography>
<Tooltip title="Copy to clipboard">
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
onChange={handleTextFieldChange('url_slug')}
placeholder="seo-friendly-url-slug"
helperText="Use lowercase letters, numbers, and hyphens only"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>
{/* Focus Keyword */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Focus Keyword
</Typography>
<Tooltip title="Copy to clipboard">
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
onChange={handleTextFieldChange('focus_keyword')}
placeholder="primary-keyword"
helperText="Your main SEO keyword for this post"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>
{/* Blog Tags */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TagIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Blog Tags
</Typography>
<Tooltip title="Copy to clipboard">
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Tags</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
<Select
multiple
value={metadata.blog_tags || []}
onChange={handleTagsChange('blog_tags')}
input={<OutlinedInput label="Tags" />}
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Add relevant tags for better categorization and discoverability
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Add 36 relevant tags for better categorization and discoverability
</Typography>
</Paper>
</Grid>
{/* Blog Categories */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<CategoryIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Blog Categories
</Typography>
<Tooltip title="Copy to clipboard">
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Categories</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
<Select
multiple
value={metadata.blog_categories || []}
onChange={handleTagsChange('blog_categories')}
input={<OutlinedInput label="Categories" />}
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Select 2-3 primary categories for your content
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Select 13 primary categories for your content
</Typography>
</Paper>
</Grid>
{/* Social Hashtags */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TagIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Social Hashtags
</Typography>
<Tooltip title="Copy to clipboard">
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</Tooltip>
</Box>
<FormControl fullWidth>
<InputLabel>Hashtags</InputLabel>
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
<Select
multiple
value={metadata.social_hashtags || []}
onChange={handleTagsChange('social_hashtags')}
input={<OutlinedInput label="Hashtags" />}
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value: string) => (
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 1 }}>
Include # symbol for social media platforms
</Alert>
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
Include # symbol (e.g., #multimodalAI). 35 hashtags recommended.
</Typography>
</Paper>
</Grid>
{/* Reading Time */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon sx={{ fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
Reading Time
</Typography>
<Tooltip title="Copy to clipboard">
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
}}
helperText="Estimated reading time for your content"
sx={textInputSx}
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
/>
</Paper>
</Grid>

View File

@@ -12,28 +12,35 @@ import {
Box,
Typography,
Paper,
Grid,
Card,
CardContent,
Chip,
Alert
Tabs,
Tab,
Tooltip,
IconButton
} from '@mui/material';
import {
Search as SearchIcon,
Code as CodeIcon,
Facebook as FacebookIcon,
Twitter as TwitterIcon,
Google as GoogleIcon
Google as GoogleIcon,
Info as InfoIcon
} from '@mui/icons-material';
interface PreviewCardProps {
metadata: any;
blogTitle: string;
previewTabValue: string;
onPreviewTabChange: (value: string) => void;
}
export const PreviewCard: React.FC<PreviewCardProps> = ({
metadata,
blogTitle
blogTitle,
previewTabValue,
onPreviewTabChange
}) => {
const getCurrentDate = () => {
return new Date().toLocaleDateString('en-US', {
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Title with Tooltip */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Live Preview
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Live Preview
</Typography>
<Tooltip
title="This is how your blog post will appear in search results and social media platforms"
arrow
placement="top"
>
<IconButton size="small" sx={{ color: 'text.secondary' }}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={3}>
{/* Google Search Results Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GoogleIcon sx={{ color: '#4285F4' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Google Search Results
{/* Platform Sub-Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={previewTabValue}
onChange={(e, newValue) => onPreviewTabChange(newValue)}
variant="scrollable"
scrollButtons="auto"
sx={{
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 500,
minHeight: 48
},
'& .Mui-selected': {
fontWeight: 600
}
}}
>
<Tab
icon={<GoogleIcon />}
iconPosition="start"
label="Google Search Results"
value="google"
/>
<Tab
icon={<FacebookIcon />}
iconPosition="start"
label="Facebook Preview"
value="facebook"
/>
<Tab
icon={<TwitterIcon />}
iconPosition="start"
label="Twitter Preview"
value="twitter"
/>
<Tab
icon={<CodeIcon />}
iconPosition="start"
label="Rich Snippets Preview"
value="richsnippets"
/>
</Tabs>
</Box>
{/* Google Search Results Preview */}
{previewTabValue === 'google' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<GoogleIcon sx={{ color: '#4285F4', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Google Search Results
</Typography>
</Box>
{/* Google SERP Preview - Light Theme (matches actual Google) */}
<Card
sx={{
background: '#ffffff',
border: 'none',
boxShadow: 'none',
maxWidth: 600
}}
>
<CardContent sx={{ p: 2.5 }}>
{/* URL - Google Blue */}
<Typography
variant="caption"
sx={{
color: '#202124',
fontSize: '14px',
lineHeight: 1.3,
mb: 0.5,
display: 'block',
fontFamily: 'arial, sans-serif'
}}
>
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
</Typography>
<Chip label="SERP Preview" size="small" color="primary" />
</Box>
{/* Title - Google Blue, hover underline */}
<Typography
variant="h6"
sx={{
color: '#1a0dab',
fontWeight: 400,
fontSize: '20px',
lineHeight: 1.3,
mb: 0.5,
cursor: 'pointer',
fontFamily: 'arial, sans-serif',
'&:hover': { textDecoration: 'underline' }
}}
>
{metadata.seo_title || blogTitle}
</Typography>
{/* Description - Google Gray */}
<Typography
variant="body2"
sx={{
color: '#4d5156',
lineHeight: 1.58,
fontSize: '14px',
fontFamily: 'arial, sans-serif',
mb: 1
}}
>
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
</Typography>
{/* Additional Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
<Typography
variant="caption"
sx={{
color: '#70757a',
fontSize: '14px',
fontFamily: 'arial, sans-serif'
}}
>
{getCurrentDate()}
</Typography>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
{metadata.reading_time || 5} min read
</Typography>
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
<>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
{metadata.blog_tags.slice(0, 2).join(', ')}
</Typography>
</>
)}
</Box>
</CardContent>
</Card>
</Paper>
)}
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
<CardContent sx={{ p: 2 }}>
{/* Facebook Preview */}
{previewTabValue === 'facebook' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1c1e21' }}>
Facebook Preview
</Typography>
<Chip label="Open Graph" size="small" sx={{ bgcolor: '#e7f3ff', color: '#1877F2' }} />
</Box>
{/* Facebook Card Preview */}
<Card
sx={{
border: '1px solid #dadde1',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 500,
background: '#ffffff',
overflow: 'hidden'
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f2f3f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #dadde1'
}}>
{metadata.open_graph?.image ? (
<Typography variant="caption" sx={{ color: '#65676b' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#65676b' }}>
No image set
</Typography>
)}
</Box>
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#1a0dab', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
<Typography
variant="caption"
sx={{
color: '#65676b',
fontSize: '12px',
mb: 0.75,
display: 'block',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
>
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography
variant="h6"
variant="subtitle1"
sx={{
color: '#1a0dab',
fontWeight: 400,
fontSize: '1.1rem',
lineHeight: 1.3,
mb: 1,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
fontWeight: 600,
mb: 1,
lineHeight: 1.33,
fontSize: '17px',
color: '#050505',
fontFamily: 'Helvetica, Arial, sans-serif'
}}
>
{metadata.seo_title || blogTitle}
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#4d5156', lineHeight: 1.4, mb: 1 }}>
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
<Typography
variant="body2"
sx={{
color: '#65676b',
lineHeight: 1.33,
fontSize: '15px',
fontFamily: 'Helvetica, Arial, sans-serif'
}}
>
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
</Box>
</CardContent>
</Card>
</Paper>
)}
{/* Twitter Preview */}
{previewTabValue === 'twitter' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f1419' }}>
Twitter Preview
</Typography>
<Chip label="Twitter Card" size="small" sx={{ bgcolor: '#e1f5fe', color: '#1DA1F2' }} />
</Box>
{/* Twitter Card Preview */}
<Card
sx={{
border: '1px solid #eff3f4',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 500,
background: '#ffffff',
overflow: 'hidden'
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f7f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #eff3f4'
}}>
{metadata.twitter_card?.image ? (
<Typography variant="caption" sx={{ color: '#536471' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#536471' }}>
No image set
</Typography>
)}
</Box>
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
{/* URL */}
<Typography
variant="caption"
sx={{
color: '#536471',
fontSize: '13px',
mb: 0.75,
display: 'block',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
</Typography>
{/* Additional Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{getCurrentDate()}
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.reading_time || 5} min read
</Typography>
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
<>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
</Typography>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.blog_tags.slice(0, 2).join(', ')}
</Typography>
</>
)}
</Box>
</CardContent>
</Card>
<Alert severity="info" sx={{ mt: 2 }}>
This is how your blog post will appear in Google search results
</Alert>
</Paper>
</Grid>
{/* Social Media Previews */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Facebook Preview
</Typography>
<Chip label="Open Graph" size="small" color="primary" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 200,
bgcolor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{metadata.open_graph?.image ? 'Image loaded' : 'No image set'}
</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#65676b', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#65676b', lineHeight: 1.4 }}>
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
</Box>
</CardContent>
</Card>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Twitter Preview
</Typography>
<Chip label="Twitter Card" size="small" color="info" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 200,
bgcolor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{metadata.twitter_card?.image ? 'Image loaded' : 'No image set'}
</Typography>
</Box>
<Box sx={{ p: 2 }}>
{/* URL */}
<Typography variant="caption" sx={{ color: '#536471', mb: 1, display: 'block' }}>
{metadata.canonical_url || 'yourwebsite.com'}
</Typography>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
</Typography>
{/* Description */}
<Typography variant="body2" sx={{ color: '#536471', lineHeight: 1.4 }}>
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
{/* Twitter handle */}
{metadata.twitter_card?.site && (
<Typography variant="caption" sx={{ color: '#536471', mt: 1, display: 'block' }}>
{metadata.twitter_card.site}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Paper>
</Grid>
{/* Rich Snippets Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<CodeIcon sx={{ color: '#34A853' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Rich Snippets Preview
</Typography>
<Chip label="JSON-LD Schema" size="small" color="success" />
</Box>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
<CardContent sx={{ p: 2 }}>
{/* Article Schema Preview */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
</Typography>
<Chip label="Article" size="small" color="success" />
</Box>
<Typography variant="body2" sx={{ color: '#4d5156', mb: 2 }}>
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
{/* Title */}
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
mb: 1,
lineHeight: 1.33,
fontSize: '15px',
color: '#0f1419',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{metadata.json_ld_schema?.author?.name && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
By {metadata.json_ld_schema.author.name}
</Typography>
</Box>
)}
{metadata.json_ld_schema?.datePublished && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
</Typography>
</Box>
)}
{metadata.reading_time && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.reading_time} min read
</Typography>
</Box>
)}
{metadata.json_ld_schema?.wordCount && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#4d5156' }}>
{metadata.json_ld_schema.wordCount} words
</Typography>
</Box>
)}
</Box>
{/* Description */}
<Typography
variant="body2"
sx={{
color: '#536471',
lineHeight: 1.33,
fontSize: '15px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
</Typography>
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: '#4d5156', display: 'block', mb: 1 }}>
Keywords:
{/* Twitter handle */}
{metadata.twitter_card?.site && (
<Typography
variant="caption"
sx={{
color: '#536471',
mt: 1,
display: 'block',
fontSize: '13px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}
>
{metadata.twitter_card.site}
</Typography>
)}
</Box>
</CardContent>
</Card>
</Paper>
)}
{/* Rich Snippets Preview */}
{previewTabValue === 'richsnippets' && (
<Paper
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<CodeIcon sx={{ color: '#34A853', fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Rich Snippets Preview
</Typography>
<Chip label="JSON-LD Schema" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
</Box>
{/* Rich Snippets Card */}
<Card
sx={{
background: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: 2,
boxShadow: 'none',
maxWidth: 600
}}
>
<CardContent sx={{ p: 3 }}>
{/* Article Schema Preview */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
</Typography>
<Chip label="Article" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
</Box>
<Typography
variant="body1"
sx={{
color: '#4d5156',
mb: 2,
lineHeight: 1.6,
fontSize: '14px'
}}
>
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
{metadata.json_ld_schema?.author?.name && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
By {metadata.json_ld_schema.author.name}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
<Chip key={index} label={keyword} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
</CardContent>
</Card>
<Alert severity="success" sx={{ mt: 2 }}>
Rich snippets help search engines understand your content and may display additional information in search results
</Alert>
</Paper>
</Grid>
{/* Metadata Summary */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon />
Metadata Summary
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(76, 175, 80, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'success.main' }}>
{metadata.optimization_score || 0}%
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Optimization Score
{metadata.json_ld_schema?.datePublished && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
</Typography>
</Box>
)}
{metadata.reading_time && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{metadata.reading_time} min read
</Typography>
</Box>
)}
{metadata.json_ld_schema?.wordCount && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
{metadata.json_ld_schema.wordCount} words
</Typography>
</Box>
)}
</Box>
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid #e0e0e0' }}>
<Typography variant="caption" sx={{ color: '#70757a', display: 'block', mb: 1, fontWeight: 500 }}>
Keywords:
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
<Chip
key={index}
label={keyword}
size="small"
variant="outlined"
sx={{ borderColor: '#e0e0e0', color: '#4d5156' }}
/>
))}
</Box>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(33, 150, 243, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
{metadata.reading_time || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Reading Time (min)
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(156, 39, 176, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'secondary.main' }}>
{metadata.blog_tags?.length || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Tags
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(255, 152, 0, 0.1)', borderRadius: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'warning.main' }}>
{metadata.blog_categories?.length || 0}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Categories
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
)}
</CardContent>
</Card>
</Paper>
)}
</Box>
);
};

View File

@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
return `${current}/${max}`;
};
// Consistent text input styling for better contrast
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#dadce0'
}
} as const;
const openGraph = metadata.open_graph || {};
const twitterCard = metadata.twitter_card || {};
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<ShareIcon sx={{ color: 'primary.main' }} />
Social Media Metadata
</Typography>
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={3}>
{/* Open Graph Section */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<LinkedInIcon sx={{ color: '#0077B5' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Open Graph Tags
</Typography>
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.title || ''}
onChange={handleNestedFieldChange('open_graph', 'title')}
placeholder="Open Graph title (60 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.description || ''}
onChange={handleNestedFieldChange('open_graph', 'description')}
placeholder="Open Graph description (160 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG Image URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.image || ''}
onChange={handleNestedFieldChange('open_graph', 'image')}
placeholder="https://example.com/image.jpg"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
OG URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={openGraph.url || ''}
onChange={handleNestedFieldChange('open_graph', 'url')}
placeholder="https://example.com/blog-post"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}>
Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
</Alert>
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
</Typography>
</Paper>
</Grid>
{/* Twitter Card Section */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Card Tags
</Typography>
<Chip label="Twitter & X" size="small" color="info" />
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Title
</Typography>
<Tooltip title="Copy to clipboard">
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.title || ''}
onChange={handleNestedFieldChange('twitter_card', 'title')}
placeholder="Twitter card title (70 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.description || ''}
onChange={handleNestedFieldChange('twitter_card', 'description')}
placeholder="Twitter card description (200 characters max)"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Image URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.image || ''}
onChange={handleNestedFieldChange('twitter_card', 'image')}
placeholder="https://example.com/twitter-image.jpg"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Site Handle
</Typography>
<Tooltip title="Copy to clipboard">
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
value={twitterCard.site || ''}
onChange={handleNestedFieldChange('twitter_card', 'site')}
placeholder="@yourwebsite"
sx={textInputSx}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}>
Twitter cards provide rich previews when your content is shared on Twitter/X
</Alert>
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
Twitter cards provide rich previews when your content is shared on Twitter/X.
</Typography>
</Paper>
</Grid>
{/* Social Media Preview */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<ShareIcon />
Social Media Preview
</Typography>
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
<Grid container spacing={2}>
{/* Facebook Preview */}
<Grid item xs={12} md={6}>
<Card sx={{ border: '1px solid #e0e0e0' }}>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<FacebookIcon sx={{ color: '#1877F2' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Facebook Preview
</Typography>
</Box>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
{openGraph.title || 'Your Blog Title'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
{openGraph.url || 'yourwebsite.com'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
{openGraph.description || 'Your meta description will appear here...'}
</Typography>
</Box>
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
{/* Twitter Preview */}
<Grid item xs={12} md={6}>
<Card sx={{ border: '1px solid #e0e0e0' }}>
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
<CardContent sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TwitterIcon sx={{ color: '#1DA1F2' }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Twitter Preview
</Typography>
</Box>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
{twitterCard.title || 'Your Blog Title'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
{twitterCard.site || '@yourwebsite'}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
{twitterCard.description || 'Your Twitter description will appear here...'}
</Typography>
</Box>

View File

@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
}) => {
const [showRawJson, setShowRawJson] = useState(false);
// Helpers for counters and consistent input styling
const getCharacterCountColor = (current: number, max: number) => {
if (current > max) return 'error';
if (current > max * 0.9) return 'warning';
return 'success';
};
const getCharacterCountText = (current: number, max: number) => {
if (current > max) return `${current}/${max} (Too long)`;
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
return `${current}/${max}`;
};
const textInputSx = {
'& .MuiInputBase-input': {
color: '#202124'
},
'& .MuiInputLabel-root': {
color: '#5f6368'
}
} as const;
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
onMetadataEdit(field, event.target.value);
};
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid container spacing={3}>
{/* Article Information */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon />
Article Schema
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.headline || ''}
onChange={handleSchemaFieldChange('headline')}
placeholder="Article headline"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Typography
variant="caption"
color={getCharacterCountColor((jsonLdSchema.headline || '').length, 110)}
>
{getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
</Typography>
</InputAdornment>
)
}}
/>
</Grid>
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.description || ''}
onChange={handleSchemaFieldChange('description')}
placeholder="Article description"
sx={textInputSx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Typography
variant="caption"
color={getCharacterCountColor((jsonLdSchema.description || '').length, 200)}
>
{getCharacterCountText((jsonLdSchema.description || '').length, 200)}
</Typography>
</InputAdornment>
)
}}
/>
</Grid>
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
</InputAdornment>
)
}}
sx={textInputSx}
/>
</Grid>
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
InputProps={{
endAdornment: <InputAdornment position="end">words</InputAdornment>
}}
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Author Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon />
Author Information
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={author.name || ''}
onChange={handleAuthorFieldChange('name')}
placeholder="Author Name"
sx={textInputSx}
/>
</Grid>
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={author['@type'] || ''}
onChange={handleAuthorFieldChange('@type')}
placeholder="Person"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publisher Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon />
Publisher Information
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={publisher.name || ''}
onChange={handlePublisherFieldChange('name')}
placeholder="Publisher Name"
sx={textInputSx}
/>
</Grid>
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={publisher.logo || ''}
onChange={handlePublisherFieldChange('logo')}
placeholder="https://example.com/logo.png"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publication Dates */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarIcon />
Publication Dates
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.datePublished || ''}
onChange={handleSchemaFieldChange('datePublished')}
InputLabelProps={{ shrink: true }}
sx={textInputSx}
/>
</Grid>
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
value={jsonLdSchema.dateModified || ''}
onChange={handleSchemaFieldChange('dateModified')}
InputLabelProps={{ shrink: true }}
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Keywords */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon />
Keywords & Categories
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
}}
placeholder="keyword1, keyword2, keyword3"
helperText="Separate keywords with commas"
sx={textInputSx}
/>
</Grid>
</Grid>
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
readOnly: true,
sx: {
fontFamily: 'monospace',
fontSize: '0.875rem'
fontSize: '0.875rem',
background: '#0f172a',
color: '#e2e8f0'
}
}}
sx={{

View File

@@ -0,0 +1,264 @@
/**
* OverallScoreCard Component
*
* Renders the compact overall SEO score summary with grade chip and
* category score tiles.
*/
import React from 'react';
import {
Card,
CardHeader,
CardContent,
Typography,
Box,
Tooltip,
Paper,
Chip,
Avatar
} from '@mui/material';
interface MetricTooltip {
title: string;
description: string;
methodology: string;
score_meaning: string;
examples: string;
}
interface OverallScoreCardProps {
overallScore: number;
overallGrade: string;
statusLabel: string;
categoryScores: Record<string, number>;
getMetricTooltip: (category: string) => MetricTooltip;
getScoreColor: (score: number) => string;
}
const getGradeMeta = (grade: string) => {
switch (grade) {
case 'A':
return {
color: '#16a34a',
background: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.18))',
tooltip: 'Grade A: Outstanding SEO health with only minor optimizations needed.'
};
case 'B':
return {
color: '#0ea5e9',
background: 'linear-gradient(135deg, rgba(14,165,233,0.12), rgba(2,132,199,0.18))',
tooltip: 'Grade B: Strong SEO foundation with several opportunities to optimize further.'
};
case 'C':
return {
color: '#d97706',
background: 'linear-gradient(135deg, rgba(251,191,36,0.14), rgba(217,119,6,0.2))',
tooltip: 'Grade C: Moderate SEO performance. Prioritize improvements in weaker categories.'
};
case 'D':
return {
color: '#ea580c',
background: 'linear-gradient(135deg, rgba(251,113,133,0.14), rgba(249,115,22,0.2))',
tooltip: 'Grade D: Significant SEO gaps detected. Address critical issues promptly.'
};
default:
return {
color: '#475569',
background: 'linear-gradient(135deg, rgba(148,163,184,0.14), rgba(100,116,139,0.2))',
tooltip: 'SEO grade unavailable. Review analysis details for more information.'
};
}
};
export const OverallScoreCard: React.FC<OverallScoreCardProps> = ({
overallScore,
overallGrade,
statusLabel,
categoryScores,
getMetricTooltip,
getScoreColor
}) => {
const gradeMeta = getGradeMeta(overallGrade);
return (
<Card
sx={{
mb: 3,
background: 'rgba(255,255,255,0.95)',
border: '1px solid rgba(0,0,0,0.08)',
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
borderRadius: 3
}}
>
<CardHeader
sx={{
pb: 0,
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}
>
Overall SEO Performance Snapshot
</Typography>
</Box>
</CardHeader>
<CardContent
sx={{
pt: 2,
pb: { xs: 2.5, md: 3 },
px: { xs: 2, md: 3 }
}}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: { xs: 3, md: 4 },
alignItems: { xs: 'stretch', md: 'flex-start' }
}}
>
<Box
sx={{
flexShrink: 0,
minWidth: { md: 240 },
display: 'flex',
flexDirection: 'column',
alignItems: { xs: 'flex-start', md: 'center' },
gap: 1.5,
background: 'linear-gradient(145deg, rgba(241,245,249,0.7), rgba(255,255,255,0.95))',
borderRadius: 2,
p: { xs: 1.5, md: 2 }
}}
>
<Box sx={{ textAlign: { xs: 'left', md: 'center' } }}>
<Typography
component="span"
sx={{
display: 'inline-flex',
alignItems: 'baseline',
gap: 1,
fontWeight: 800,
fontSize: { xs: '2.4rem', md: '2.8rem' },
lineHeight: 1,
background: 'linear-gradient(120deg, #22c55e, #4ade80)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
{overallScore}
<Typography
component="span"
variant="caption"
sx={{ color: '#64748b', fontWeight: 600 }}
>
/100
</Typography>
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.5 }}>
Overall Score
</Typography>
</Box>
<Tooltip title={gradeMeta.tooltip} arrow placement="top">
<Chip
label={statusLabel}
avatar={
<Avatar
sx={{
bgcolor: '#fff',
color: gradeMeta.color,
fontWeight: 700
}}
>
{overallGrade}
</Avatar>
}
sx={{
fontWeight: 600,
px: 2.2,
py: 0.5,
letterSpacing: 0.3,
color: gradeMeta.color,
background: gradeMeta.background
}}
/>
</Tooltip>
</Box>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: 'repeat(2, minmax(110px, 1fr))', sm: 'repeat(3, minmax(110px, 1fr))' },
gap: 1.5
}}
>
{Object.entries(categoryScores).map(([category, score]) => {
const tooltip = getMetricTooltip(category);
return (
<Tooltip
key={category}
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 0.75, color: '#475569' }}>
{tooltip.description}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Methodology:</strong> {tooltip.methodology}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Score Meaning:</strong> {tooltip.score_meaning}
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Examples:</strong> {tooltip.examples}
</Typography>
</Box>
}
arrow
placement="top"
>
<Paper
sx={{
p: 1.4,
textAlign: 'center',
borderRadius: 2,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
boxShadow: '0 8px 18px rgba(15,23,42,0.06)',
cursor: 'help'
}}
>
<Typography
variant="h6"
sx={{ fontWeight: 800, color: getScoreColor(score), mb: 0.35 }}
>
{score}
</Typography>
<Typography
variant="caption"
sx={{ color: '#64748b', textTransform: 'capitalize', fontWeight: 600 }}
>
{category.replace('_', ' ')}
</Typography>
</Paper>
</Tooltip>
);
})}
</Box>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default OverallScoreCard;

View File

@@ -1,6 +1,6 @@
/**
* Readability Analysis Component
*
*
* Displays comprehensive readability analysis including readability metrics,
* content statistics, sentence/paragraph analysis, and target audience information.
*/
@@ -14,7 +14,7 @@ import {
IconButton,
Tooltip
} from '@mui/material';
import {
import {
MenuBook
} from '@mui/icons-material';
@@ -57,109 +57,186 @@ interface ReadabilityAnalysisProps {
};
}
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
detailedAnalysis,
visualizationData
const cardStyles = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 30px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const sectionTitleSx = {
fontWeight: 700,
letterSpacing: 0.2,
color: '#0f172a',
mb: 2
} as const;
const statRowSx = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.5
} as const;
const statLabelSx = {
color: '#475569',
fontWeight: 500
} as const;
const statValueSx = {
color: '#0f172a',
fontWeight: 700
} as const;
const metricRowSx = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.65rem 0.85rem',
borderRadius: 12,
backgroundColor: '#f1f5f9',
cursor: 'help',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 10px 20px rgba(15,23,42,0.08)'
}
} as const;
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
detailedAnalysis,
visualizationData
}) => {
const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
const getMetricDetails = (metric: string, value: number) => {
const tooltips: Record<string, { description: string; interpretation: string }> = {
flesch_reading_ease: {
description: 'Measures how easy text is to read (0-100 scale).',
interpretation: value >= 80 ? 'Very Easy' : value >= 60 ? 'Standard' : 'Challenging'
},
flesch_kincaid_grade: {
description: 'U.S. grade level required to understand the text.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
gunning_fog: {
description: 'Years of formal education needed for comprehension.',
interpretation: value <= 12 ? 'Easy' : value <= 16 ? 'Moderate' : 'Advanced'
},
smog_index: {
description: 'Estimates the years of education needed to understand the text.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
automated_readability: {
description: 'Automated readability score based on characters per word.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
},
coleman_liau: {
description: 'Readability based on characters per word and sentence length.',
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
}
};
return (
tooltips[metric] || {
description: 'Readability metric',
interpretation: 'No interpretation available'
}
);
};
const renderStatRow = (label: React.ReactNode, value: React.ReactNode) => (
<Box sx={statRowSx}>
<Typography variant="body2" sx={statLabelSx}>
{label}
</Typography>
<Typography variant="body2" sx={statValueSx}>
{value}
</Typography>
</Box>
);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<MenuBook sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography
variant="h6"
component="h3"
sx={{ fontWeight: 700, letterSpacing: 0.3, color: '#0f172a' }}
>
Readability Analysis
</Typography>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Paper sx={cardStyles}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Readability Metrics
</Typography>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Readability Analysis
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Measures how easy your content is to read and understand.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Average Sentence Length:</strong> 15-20 words is optimal
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
<strong>Sentence Length:</strong> 15-20 words is optimal
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
</Typography>
</Box>
}
arrow
>
<IconButton size="small" sx={{ color: 'primary.main' }}>
<MenuBook />
<MenuBook fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
const getReadabilityTooltip = (metric: string, value: number) => {
const tooltips = {
flesch_reading_ease: {
description: "Measures how easy text is to read (0-100 scale)",
interpretation: value >= 80 ? "Very Easy" : value >= 60 ? "Standard" : "Difficult"
},
flesch_kincaid_grade: {
description: "U.S. grade level needed to understand the text",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
gunning_fog: {
description: "Years of formal education needed to understand the text",
interpretation: value <= 12 ? "Easy" : value <= 16 ? "Moderate" : "Difficult"
},
smog_index: {
description: "Simple Measure of Gobbledygook - readability formula",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
automated_readability: {
description: "Automated Readability Index based on character count",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
},
coleman_liau: {
description: "Readability test based on average sentence length and characters per word",
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
}
};
return tooltips[metric as keyof typeof tooltips] || { description: "Readability metric", interpretation: "N/A" };
};
const tooltip = getReadabilityTooltip(metric, value);
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{Object.keys(readabilityMetrics).length > 0 ? (
Object.entries(readabilityMetrics).map(([metric, value]) => {
const { description, interpretation } = getMetricDetails(metric, value);
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
return (
<Tooltip
key={metric}
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
{description}
</Typography>
<Typography variant="caption">
<strong>Interpretation:</strong> {tooltip.interpretation}
<Typography variant="caption" sx={{ color: '#64748b' }}>
<strong>Interpretation:</strong> {interpretation}
</Typography>
</Box>
}
arrow
placement="top"
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)', cursor: 'help' }}>
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
<Box sx={metricRowSx}>
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
{metric.replace('_', ' ')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
{value.toFixed(1)}
</Typography>
</Box>
@@ -167,116 +244,72 @@ export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
);
})
) : (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
No readability metrics available. This may indicate an issue with the content analysis.
</Typography>
)}
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Content Statistics
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Word Count</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Sections</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Paragraphs</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Sentences</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Unique Words</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Vocabulary Diversity</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
{renderStatRow(
'Vocabulary Diversity',
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
: 'N/A'
)}
</Box>
</Paper>
</Grid>
</Grid>
{/* Additional Readability Metrics */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Sentence & Paragraph Analysis
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Avg Sentence Length</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Avg Paragraph Length</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Transition Words</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow(
'Average Sentence Length',
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
: 'N/A'
)}
{renderStatRow(
'Average Paragraph Length',
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
: 'N/A'
)}
{renderStatRow(
'Transition Words Used',
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
)}
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={cardStyles}>
<Typography variant="subtitle1" sx={sectionTitleSx}>
Target Audience
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Reading Level</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Content Depth Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2">Flow Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
</Box>
</Paper>
</Grid>

View File

@@ -1,6 +1,6 @@
/**
* Recommendations Component
*
*
* Displays actionable SEO recommendations with priority indicators,
* category tags, and impact descriptions.
*/
@@ -12,7 +12,7 @@ import {
Paper,
Chip
} from '@mui/material';
import {
import {
Lightbulb,
CheckCircle,
Cancel,
@@ -30,78 +30,107 @@ interface RecommendationsProps {
recommendations: Recommendation[];
}
const priorityStyles: Record<string, { color: string; gradient: string }> = {
High: { color: '#dc2626', gradient: 'linear-gradient(135deg, rgba(248,113,113,0.12), rgba(239,68,68,0.18))' },
Medium: { color: '#d97706', gradient: 'linear-gradient(135deg, rgba(251,191,36,0.12), rgba(217,119,6,0.16))' },
Low: { color: '#16a34a', gradient: 'linear-gradient(135deg, rgba(74,222,128,0.12), rgba(22,163,74,0.16))' },
default: { color: '#475569', gradient: 'linear-gradient(135deg, rgba(148,163,184,0.1), rgba(100,116,139,0.14))' }
};
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'High': return 'error.main';
case 'Medium': return 'warning.main';
case 'Low': return 'success.main';
default: return 'text.secondary';
}
};
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
default: return <Warning sx={{ fontSize: 16 }} />;
case 'High':
return <Cancel sx={{ fontSize: 18 }} />;
case 'Medium':
return <Warning sx={{ fontSize: 18 }} />;
case 'Low':
return <CheckCircle sx={{ fontSize: 18 }} />;
default:
return <Warning sx={{ fontSize: 18 }} />;
}
};
const getScoreBadgeVariant = (score: number) => {
if (score >= 80) return 'success';
if (score >= 60) return 'warning';
return 'error';
const getChipColor = (priority: string) => {
switch (priority) {
case 'High':
return 'error';
case 'Medium':
return 'warning';
case 'Low':
return 'success';
default:
return 'default';
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Lightbulb sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Actionable Recommendations
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{recommendations.map((rec, index) => (
<Paper
key={index}
sx={{
p: 3,
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
borderRadius: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
{getPriorityIcon(rec.priority)}
</Box>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Chip
label={rec.category}
variant="outlined"
size="small"
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
/>
<Chip
label={rec.priority}
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
size="small"
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{recommendations.map((rec, index) => {
const styles = priorityStyles[rec.priority] || priorityStyles.default;
return (
<Paper
key={index}
sx={{
p: 3,
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 3,
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
color: '#0f172a'
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box
sx={{
mt: 0.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '999px',
background: styles.gradient,
color: getPriorityColor(rec.priority)
}}
>
{getPriorityIcon(rec.priority)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Chip
label={rec.category}
variant="outlined"
size="small"
sx={{ borderColor: '#cbd5f5', color: '#475569', fontWeight: 600 }}
/>
<Chip
label={rec.priority}
color={getChipColor(rec.priority)}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
<Typography variant="body1" sx={{ lineHeight: 1.6, color: '#1f2937' }}>
{rec.recommendation}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{rec.impact}
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 1 }}>
{rec.recommendation}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{rec.impact}
</Typography>
</Box>
</Box>
</Paper>
))}
</Paper>
);
})}
</Box>
</Box>
);

View File

@@ -1,6 +1,6 @@
/**
* Structure Analysis Component
*
*
* Displays comprehensive content structure analysis including structure overview,
* content elements detection, and heading structure analysis.
*/
@@ -14,7 +14,7 @@ import {
Chip,
Tooltip
} from '@mui/material';
import {
import {
BarChart
} from '@mui/icons-material';
@@ -52,127 +52,157 @@ interface StructureAnalysisProps {
};
}
const baseCard = {
p: 3,
backgroundColor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 2,
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
color: '#0f172a',
minHeight: '100%'
} as const;
const infoRow = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 0',
cursor: 'help'
} as const;
const statLabel = {
color: '#475569',
fontWeight: 500
} as const;
const statValue = {
color: '#0f172a',
fontWeight: 700
} as const;
const highlightCard = (borderColor: string) => ({
p: 2,
borderRadius: 2,
border: `1px solid ${borderColor}`,
background: `linear-gradient(140deg, ${borderColor}15, ${borderColor}22)`
});
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
const structure = detailedAnalysis?.content_structure;
const quality = detailedAnalysis?.content_quality;
const headings = detailedAnalysis?.heading_structure;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<BarChart sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
Content Structure Analysis
</Typography>
</Box>
<Grid container spacing={3}>
{/* Content Structure Overview */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Structure Overview
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Sections
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Number of main content sections (H2 headings) in your blog post.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good sectioning improves readability and helps search engines understand your content structure.
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Sections</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_sections || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Paragraphs
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Number of paragraphs in your content (excluding headings).
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Appropriate paragraph count indicates good content depth and organization.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Paragraphs</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_paragraphs || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Total Sentences
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Total number of sentences in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Sentence count affects readability and content comprehensiveness.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Total Sentences</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.total_sentences || 'N/A'}
</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
Structure Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
Overall score (0-100) for your content's structural organization.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Section count, paragraph count, introduction/conclusion presence
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Well-structured content ranks better and provides better user experience.
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/conclusion presence.
</Typography>
</Box>
}
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Structure Score</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
<Typography variant="body2" sx={statValue}>
{structure?.structure_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
{/* Content Elements */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Content Elements
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Introduction Section
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content has a clear introduction that sets context and expectations.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> Introductions help readers understand what they'll learn and improve engagement.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Clear introductions help search engines understand your content's purpose.
</Typography>
</Box>
}
title="Whether your content has a clear introduction that sets context and expectations."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Introduction</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
<Chip
label={structure?.has_introduction ? 'Yes' : 'No'}
color={structure?.has_introduction ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Conclusion Section
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content has a clear conclusion that summarizes key points.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> Conclusions help readers retain information and provide closure.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Good conclusions can improve time on page and reduce bounce rate.
</Typography>
</Box>
}
title="Whether your content ends with a clear conclusion summarizing key points."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Conclusion</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
<Chip
label={structure?.has_conclusion ? 'Yes' : 'No'}
color={structure?.has_conclusion ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Call to Action
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Whether your content includes a clear call to action for readers.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> CTAs guide readers to take desired actions and improve conversion rates.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Strong CTAs can improve user engagement metrics.
</Typography>
</Box>
}
title="Whether your content includes a clear call to action for readers."
arrow
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
<Typography variant="body2">Has Call to Action</Typography>
<Chip
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
<Box sx={infoRow}>
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
<Chip
label={structure?.has_call_to_action ? 'Yes' : 'No'}
color={structure?.has_call_to_action ? 'success' : 'error'}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
</Tooltip>
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
{/* Content Quality Metrics */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Content Quality Metrics
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Word Count
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Total number of words in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 800-2000 words for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Longer content typically ranks better and provides more value to readers.
</Typography>
</Box>
}
title="Total number of words in your content. Longer content typically ranks better."
arrow
>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
Word Count
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.word_count || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.word_count || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Vocabulary Diversity
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Ratio of unique words to total words, indicating content variety.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 0.4-0.7 (40-70% unique words)
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Higher diversity indicates richer, more engaging content.
</Typography>
</Box>
}
title="Ratio of unique words to total words, indicating content variety and richness."
arrow
>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
Vocabulary Diversity
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.vocabulary_diversity !== undefined
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
: 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Content Depth Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how comprehensive and detailed your content is.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Word count, section depth, information density
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Deeper content provides more value and ranks better in search results.
</Typography>
</Box>
}
title="Score (0-100) indicating how comprehensive and detailed your content is."
arrow
>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
Content Depth Score
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.content_depth_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Flow Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how well your content flows from one idea to the next.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> Transition words, sentence variety, logical progression
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good flow improves readability and keeps readers engaged.
</Typography>
</Box>
}
title="Score (0-100) indicating how well your content flows from one idea to the next."
arrow
>
<Box sx={{ p: 2, background: 'rgba(255, 152, 0, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 152, 0, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main', mb: 1 }}>
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
Flow Score
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.flow_score || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Transition Words
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Number of transition words used to connect ideas and improve flow.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Optimal Range:</strong> 5-15 transition words for most blog posts
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Transition words improve readability and help readers follow your logic.
</Typography>
</Box>
}
title="Number of transition words used higher values suggest smoother narrative flow."
arrow
>
<Box sx={{ p: 2, background: 'rgba(244, 67, 54, 0.1)', borderRadius: 2, border: '1px solid rgba(244, 67, 54, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'error.main', mb: 1 }}>
Transition Words
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
Transition Words Used
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.transition_words_used || 'N/A'}
</Typography>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Unique Words
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Number of unique words used in your content.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Why it matters:</strong> More unique words indicate richer vocabulary and better content variety.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>SEO Impact:</strong> Diverse vocabulary can help with semantic SEO and topic coverage.
</Typography>
</Box>
}
title="Average unique words used throughout the article. Indicates lexical richness."
arrow
>
<Box sx={{ p: 2, background: 'rgba(0, 150, 136, 0.1)', borderRadius: 2, border: '1px solid rgba(0, 150, 136, 0.3)', cursor: 'help' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'info.main', mb: 1 }}>
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
Unique Words
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{quality?.unique_words || 'N/A'}
</Typography>
</Box>
</Tooltip>
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
</Grid>
{/* Heading Structure */}
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Heading Structure Analysis
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
{headings && (
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12}>
<Paper sx={baseCard}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
Heading Structure
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
H1 Headings
</Typography>
))}
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h1_count}
</Typography>
))}
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
</Typography>
)}
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
</Typography>
{detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
• {heading}
</Typography>
))}
{detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
</Typography>
)}
</Box>
</Grid>
</Grid>
<Box sx={{ mt: 2, p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Heading Hierarchy Score
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Score (0-100) indicating how well your heading structure follows SEO best practices.
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Scoring Factors:</strong> H1 presence, logical hierarchy, keyword usage in headings
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Why it matters:</strong> Good heading structure helps search engines understand your content and improves readability.
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
</Typography>
</Box>
}
arrow
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, cursor: 'help' }}>
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
</Typography>
</Tooltip>
</Box>
{/* Structure Recommendations */}
{detailedAnalysis?.content_structure?.recommendations && detailedAnalysis.content_structure.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(255, 193, 7, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 193, 7, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'warning.main' }}>
Structure Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.content_structure.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
• {recommendation}
</Grid>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
H2 Headings
</Typography>
))}
</Box>
</Box>
)}
{/* Heading Recommendations */}
{detailedAnalysis?.heading_structure?.recommendations && detailedAnalysis.heading_structure.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'primary.main' }}>
Heading Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.heading_structure.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
• {recommendation}
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h2_count}
</Typography>
))}
</Box>
</Box>
)}
{/* Content Quality Recommendations */}
{detailedAnalysis?.content_quality?.recommendations && detailedAnalysis.content_quality.recommendations.length > 0 && (
<Box sx={{ mt: 2, p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'success.main' }}>
Content Quality Recommendations
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{detailedAnalysis.content_quality.recommendations.map((recommendation: string, index: number) => (
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
{recommendation}
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
</Typography>
))}
</Box>
</Box>
)}
</Paper>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0ea5e9', mb: 1 }}>
H3 Headings
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
{headings.h3_count}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Grid>
)}
</Box>
);
};

View File

@@ -23,7 +23,9 @@ import {
Grid,
Paper,
IconButton,
Tooltip
Tooltip,
Avatar,
CircularProgress
} from '@mui/material';
import { apiClient } from '../../api/client';
import {
@@ -32,11 +34,11 @@ import {
Warning,
TrendingUp,
Search,
BarChart,
Refresh,
Close
} from '@mui/icons-material';
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
import OverallScoreCard from './SEO/OverallScoreCard';
interface SEOAnalysisResult {
overall_score: number;
@@ -139,7 +141,27 @@ interface SEOAnalysisModalProps {
blogContent: string;
blogTitle?: string;
researchData: any;
onApplyRecommendations?: (recommendations: any[]) => void;
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
// Simple content hashing helper (SHA-256)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getSeoCacheKey(contentHash: string, title?: string) {
return `seo_cache:${contentHash}:${title || ''}`;
}
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
blogContent,
blogTitle,
researchData,
onApplyRecommendations
onApplyRecommendations,
onAnalysisComplete
}) => {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const [progressMessage, setProgressMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState('recommendations');
const [contentHash, setContentHash] = useState<string>('');
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
// Debug logging
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
const runSEOAnalysis = useCallback(async () => {
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
try {
setIsAnalyzing(true);
setError(null);
setProgress(0);
setProgressMessage('Starting SEO analysis...');
// Simulate progress updates (in real implementation, this would be SSE)
// Cache check
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
const cacheKey = getSeoCacheKey(hash, blogTitle);
if (!forceRefresh) {
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
if (cached) {
const parsed = JSON.parse(cached);
setAnalysisResult(parsed as SEOAnalysisResult);
setIsAnalyzing(false);
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed as SEOAnalysisResult);
}
return;
}
}
// Simulated progress
const progressStages = [
{ progress: 20, message: 'Extracting keywords from research data...' },
{ progress: 40, message: 'Analyzing content structure and readability...' },
@@ -182,7 +224,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Make API call to analyze blog content
// Backend call
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
blog_content: blogContent,
blog_title: blogTitle,
@@ -191,15 +233,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const result = response.data;
console.log('🔍 Backend SEO Analysis Response:', result);
// Convert API response to frontend format - fail fast if data is missing
if (!result.success) {
throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
}
if (!result.overall_score && result.overall_score !== 0) {
throw new Error('Invalid SEO score received from API');
}
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
const convertedResult: SEOAnalysisResult = {
overall_score: result.overall_score,
@@ -256,13 +291,44 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
};
setAnalysisResult(convertedResult);
// Save to cache
try {
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
const key = getSeoCacheKey(h, blogTitle);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(convertedResult));
}
} catch {}
setIsAnalyzing(false);
// Notify parent that analysis is complete (fresh analysis)
if (onAnalysisComplete) {
onAnalysisComplete(convertedResult);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Analysis failed');
setIsAnalyzing(false);
}
}, [blogContent, blogTitle, researchData]);
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
// Precompute hash when modal opens
useEffect(() => {
if (isOpen) {
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
})();
}
}, [isOpen, blogContent, blogTitle]);
useEffect(() => {
if (isOpen && !analysisResult) {
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
const getScoreColor = (score: number) => {
if (score >= 80) return 'success.main';
@@ -270,13 +336,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
return 'error.main';
};
const getScoreBadgeVariant = (score: number) => {
if (score >= 80) return 'success';
if (score >= 60) return 'warning';
return 'error';
};
// Tooltip content for each metric
const getMetricTooltip = (category: string) => {
const tooltips = {
@@ -326,12 +385,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
};
useEffect(() => {
if (isOpen && !analysisResult) {
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
return (
<Dialog
open={isOpen}
@@ -342,14 +395,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
sx: {
maxHeight: '90vh',
borderRadius: 3,
background: 'rgba(255, 255, 255, 0.98)',
backgroundColor: '#f8fafc',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(0,0,0,0.1)',
color: 'text.primary'
border: '1px solid rgba(148,163,184,0.25)',
color: '#0f172a'
}
}}
>
<DialogContent sx={{ p: 0 }}>
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
SEO Analysis Results
</Typography>
</Box>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={() => {
setAnalysisResult(null);
runSEOAnalysis(true);
}}
>
Refresh
</Button>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />
</IconButton>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
Comprehensive analysis of your blog content's SEO optimization
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
{analysisResult && (
<Box sx={{ p: 3 }}>
{/* Overall Score Section */}
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
<CardHeader>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BarChart sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
Overall SEO Score
</Typography>
</Box>
</CardHeader>
<CardContent>
<Grid container spacing={3} alignItems="center">
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h2"
sx={{
fontWeight: 'bold',
color: getScoreColor(analysisResult.overall_score),
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
{analysisResult.overall_score}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Overall Score
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{analysisResult.analysis_summary.overall_grade}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Grade
</Typography>
</Box>
</Grid>
<Grid item xs={4}>
<Box sx={{ textAlign: 'center' }}>
<Chip
label={analysisResult.analysis_summary.status}
color={getScoreBadgeVariant(analysisResult.overall_score)}
variant="filled"
sx={{
fontWeight: 600,
fontSize: '0.9rem',
px: 2,
py: 1
}}
/>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Category Scores */}
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
<CardHeader>
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
Category Breakdown
</Typography>
</CardHeader>
<CardContent>
<Grid container spacing={2}>
{Object.entries(analysisResult.category_scores).map(([category, score]) => {
const tooltip = getMetricTooltip(category);
return (
<Grid item xs={6} md={4} key={category}>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
<strong>Methodology:</strong> {tooltip.methodology}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
<strong>Score Meaning:</strong> {tooltip.score_meaning}
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Examples:</strong> {tooltip.examples}
</Typography>
</Box>
}
arrow
placement="top"
>
<Paper
sx={{
p: 2,
textAlign: 'center',
background: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(0,0,0,0.1)',
borderRadius: 2,
cursor: 'help',
'&:hover': {
background: 'rgba(255,255,255,0.9)',
transform: 'translateY(-2px)',
transition: 'all 0.2s ease-in-out'
}
}}
>
<Typography
variant="h4"
sx={{
fontWeight: 'bold',
color: getScoreColor(score),
mb: 1
}}
>
{score}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
{category.replace('_', ' ')}
</Typography>
</Paper>
</Tooltip>
</Grid>
);
})}
</Grid>
</CardContent>
</Card>
<OverallScoreCard
overallScore={analysisResult.overall_score}
overallGrade={analysisResult.analysis_summary.overall_grade}
statusLabel={analysisResult.analysis_summary.status}
categoryScores={analysisResult.category_scores}
getMetricTooltip={getMetricTooltip}
getScoreColor={getScoreColor}
/>
{/* Detailed Analysis Tabs */}
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
@@ -603,43 +545,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TrendingUp sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
AI-Powered Insights
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Content Summary
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
{analysisResult.analysis_summary.ai_summary}
</Typography>
</Paper>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Key Strengths
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
<Typography variant="body2">{strength}</Typography>
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
</Box>
))}
</Box>
</Paper>
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Areas for Improvement
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
<Typography variant="body2">{weakness}</Typography>
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
</Box>
))}
</Box>
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
{/* Action Buttons */}
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
{applyError && (
<Alert severity="error" sx={{ mb: 2 }}>
<Cancel sx={{ mr: 1 }} />
{applyError}
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }}>
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
Close
</Button>
<Button
variant="contained"
onClick={() => {
if (onApplyRecommendations) {
onApplyRecommendations(analysisResult.actionable_recommendations);
onClick={async () => {
if (!onApplyRecommendations) return;
setApplyError(null);
setIsApplying(true);
try {
await onApplyRecommendations(analysisResult.actionable_recommendations);
// Increased delay to ensure sections are fully updated and phase stays in SEO
setTimeout(() => {
onClose();
}, 200);
} catch (applyErr: any) {
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
} finally {
setIsApplying(false);
}
onClose();
}}
disabled={!onApplyRecommendations}
disabled={!onApplyRecommendations || isApplying}
sx={{
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
'&:hover': {
@@ -672,7 +628,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}
}}
>
Apply Recommendations
{isApplying ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" />
Applying...
</Box>
) : (
'Apply Recommendations'
)}
</Button>
</Box>
</Box>

View File

@@ -9,7 +9,7 @@
* - Integration with backend metadata generation
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogTitle,
@@ -23,7 +23,8 @@ import {
CircularProgress,
Alert,
IconButton,
Chip
Chip,
Tooltip
} from '@mui/material';
import {
Close as CloseIcon,
@@ -42,6 +43,7 @@ import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
import { subscribeImage } from '../../utils/imageBus';
interface SEOMetadataModalProps {
isOpen: boolean;
@@ -49,6 +51,8 @@ interface SEOMetadataModalProps {
blogContent: string;
blogTitle: string;
researchData: any;
outline?: any[]; // Add outline structure
seoAnalysis?: any; // Add SEO analysis results
onMetadataGenerated: (metadata: any) => void;
}
@@ -71,20 +75,55 @@ interface SEOMetadataResult {
error?: string;
}
// Cache helper functions (similar to SEOAnalysisModal)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getMetadataCacheKey(contentHash: string, title?: string): string {
return `seo_metadata_cache:${contentHash}:${title || ''}`;
}
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
isOpen,
onClose,
blogContent,
blogTitle,
researchData,
outline,
seoAnalysis,
onMetadataGenerated
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState('core');
const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first
const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms
const [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
const [contentHash, setContentHash] = useState<string>('');
// Subscribe to image generation bus to auto-fill OG/Twitter image fields
useEffect(() => {
const unsub = subscribeImage(({ base64 }: { base64: string }) => {
setEditableMetadata(prev => {
const next = { ...(prev || metadataResult || {}) } as any;
next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` };
next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` };
return next;
});
});
return unsub;
}, [metadataResult]);
// Debug logging
useEffect(() => {
@@ -96,19 +135,67 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
});
}, [isOpen, blogContent, blogTitle, researchData]);
const generateMetadata = async () => {
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes (but keep result for next time)
setError(null);
setIsGenerating(false);
}
}, [isOpen]);
// Auto-generate metadata when modal opens (only once)
const hasAutoGeneratedRef = React.useRef(false);
useEffect(() => {
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
hasAutoGeneratedRef.current = true;
generateMetadata(false); // Auto-generate from cache or API
}
if (!isOpen) {
hasAutoGeneratedRef.current = false; // Reset when modal closes
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); // Only trigger when modal opens
const generateMetadata = useCallback(async (forceRefresh = false) => {
try {
setIsGenerating(true);
setError(null);
setMetadataResult(null);
if (forceRefresh) {
setMetadataResult(null);
}
console.log('🚀 Starting SEO metadata generation...');
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
// Calculate content hash for caching
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(hash);
const cacheKey = getMetadataCacheKey(hash, blogTitle);
// Check cache first (unless force refresh)
if (!forceRefresh && typeof window !== 'undefined') {
const cached = window.localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached) as SEOMetadataResult;
console.log('✅ Using cached SEO metadata');
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
return;
} catch (e) {
console.warn('Failed to parse cached metadata:', e);
}
}
}
// Make API call to generate metadata
const response = await apiClient.post('/api/blog/seo/metadata', {
content: blogContent,
title: blogTitle,
research_data: researchData
research_data: researchData,
outline: outline || null,
seo_analysis: seoAnalysis || null
});
const result = response.data;
@@ -118,6 +205,16 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
throw new Error(result.error || 'Metadata generation failed');
}
// Cache the result
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(cacheKey, JSON.stringify(result));
console.log('💾 SEO metadata cached');
} catch (e) {
console.warn('Failed to cache metadata:', e);
}
}
setMetadataResult(result);
setEditableMetadata(result);
console.log('📊 Metadata result set:', result);
@@ -128,7 +225,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} finally {
setIsGenerating(false);
}
};
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
@@ -159,6 +256,23 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
};
/**
* Handle Apply Metadata button click
*
* This saves the generated/edited metadata to the parent component's state.
* The metadata is then used when publishing to platforms:
* - WordPress: Requires SEO metadata for proper post creation with SEO fields
* - Wix: Currently doesn't require metadata, but could be added in future
*
* The metadata includes:
* - SEO title, meta description, URL slug
* - Blog tags, categories, focus keyword
* - Open Graph tags (Facebook/LinkedIn)
* - Twitter Card tags
* - JSON-LD structured data (Schema.org Article)
*
* All of these will be passed to the platform's API when publishing.
*/
const handleApplyMetadata = () => {
if (editableMetadata) {
onMetadataGenerated(editableMetadata);
@@ -222,32 +336,26 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
/>
)}
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{metadataResult && (
<Tooltip title="Regenerate SEO metadata">
<IconButton
onClick={() => generateMetadata(true)}
size="small"
disabled={isGenerating}
color="primary"
>
<RefreshIcon />
</IconButton>
</Tooltip>
)}
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
{!metadataResult && !isGenerating && (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Generate Comprehensive SEO Metadata
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: 'text.secondary' }}>
Create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.
</Typography>
<Button
variant="contained"
size="large"
onClick={generateMetadata}
startIcon={<RefreshIcon />}
sx={{ px: 4 }}
>
Generate SEO Metadata
</Button>
</Box>
)}
{isGenerating && (
<Box sx={{ p: 4, textAlign: 'center' }}>
<CircularProgress size={60} sx={{ mb: 2 }} />
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
</Alert>
<Button
variant="outlined"
onClick={generateMetadata}
onClick={() => generateMetadata(true)}
startIcon={<RefreshIcon />}
>
Try Again
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
scrollButtons="auto"
sx={{ minHeight: 48 }}
>
{['core', 'social', 'structured', 'preview'].map((tab) => (
{['preview', 'core', 'social', 'structured'].map((tab) => (
<Tab
key={tab}
value={tab}
@@ -332,6 +440,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
<PreviewCard
metadata={editableMetadata || metadataResult}
blogTitle={blogTitle}
previewTabValue={previewTabValue}
onPreviewTabChange={setPreviewTabValue}
/>
)}
</Box>

View File

@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
return (
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
<div>Score: {analysis.seo_score}</div>
{!!analysis.recommendations?.length && (
<div>Score: {analysis.overall_score}</div>
{!!analysis.analysis_summary && (
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
Grade {analysis.analysis_summary.overall_grade} · {analysis.analysis_summary.status}
</div>
)}
{!!analysis.actionable_recommendations?.length && (
<ul>
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
{analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
<li key={index}>
<strong>{rec.category}:</strong> {rec.recommendation}
</li>
))}
</ul>
)}
</div>

View File

@@ -13,17 +13,35 @@ interface SuggestionsGeneratorProps {
contentConfirmed?: boolean;
}
export const useSuggestions = (
research: BlogResearchResponse | null,
outline: BlogOutlineSection[],
outlineConfirmed: boolean = false,
researchPolling?: { isPolling: boolean; currentStatus: string },
outlinePolling?: { isPolling: boolean; currentStatus: string },
mediumPolling?: { isPolling: boolean; currentStatus: string },
hasContent: boolean = false,
flowAnalysisCompleted: boolean = false,
contentConfirmed: boolean = false
) => {
interface SuggestionContext {
research: BlogResearchResponse | null;
outline: BlogOutlineSection[];
outlineConfirmed?: boolean;
researchPolling?: { isPolling: boolean; currentStatus: string };
outlinePolling?: { isPolling: boolean; currentStatus: string };
mediumPolling?: { isPolling: boolean; currentStatus: string };
hasContent?: boolean;
flowAnalysisCompleted?: boolean;
contentConfirmed?: boolean;
seoAnalysis?: any;
seoMetadata?: any;
seoRecommendationsApplied?: boolean;
}
export const useSuggestions = ({
research,
outline,
outlineConfirmed = false,
researchPolling,
outlinePolling,
mediumPolling,
hasContent = false,
flowAnalysisCompleted = false,
contentConfirmed = false,
seoAnalysis = null,
seoMetadata = null,
seoRecommendationsApplied = false
}: SuggestionContext) => {
return useMemo(() => {
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
@@ -66,14 +84,14 @@ export const useSuggestions = (
if (!research) {
items.push({
title: '🔎 Start Research',
message: "I want to research a topic for my blog",
message: "showResearchForm",
priority: 'high'
});
} else if (research && outline.length === 0) {
// Research completed, guide user to outline creation
items.push({
title: 'Next: Create Outline',
message: 'Let\'s proceed to create an outline based on the research results',
message: 'Research is complete. Please generate the blog outline now using the existing research data. Use the generateOutline action immediately without asking for additional information.',
priority: 'high'
});
items.push({
@@ -82,13 +100,13 @@ export const useSuggestions = (
});
items.push({
title: '🎨 Create Custom Outline',
message: 'I want to create an outline with my own specific instructions and requirements'
message: 'I want to create an outline with my own specific instructions and requirements. Please ask me for my custom requirements.'
});
} else if (outline.length > 0 && !outlineConfirmed) {
// Outline created but not confirmed - focus on outline review and confirmation
items.push({
title: 'Next: Confirm & Generate Content',
message: 'I confirm the outline and am ready to generate content',
message: 'The outline is ready. Confirm the current outline and begin content generation now. Call confirmOutlineAndGenerateContent immediately and do not ask for extra confirmation.',
priority: 'high'
});
items.push({
@@ -106,12 +124,6 @@ export const useSuggestions = (
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation and optimization
if (hasContent && !contentConfirmed) {
// User has content but hasn't confirmed it yet - show content review suggestions
items.push({
title: 'Next: Confirm Blog Content',
message: 'I have reviewed and confirmed my blog content is ready for the next stage',
priority: 'high'
});
items.push({
title: '🔄 ReWrite Blog',
message: 'I want to rewrite my blog with different approach, tone, or focus'
@@ -121,24 +133,78 @@ export const useSuggestions = (
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
items.push({
title: '📈 Run SEO Analysis',
message: 'Analyze SEO for my blog post'
title: 'Next: Run SEO Analysis',
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
});
} else if (hasContent && contentConfirmed) {
// Content confirmed - move to SEO stage
items.push({
title: '📈 Run SEO Analysis',
message: 'Analyze SEO for my blog post',
priority: 'high'
});
items.push({
title: '🧾 Generate SEO Metadata',
message: 'Generate SEO metadata and title'
});
items.push({
title: '🚀 Publish to WordPress',
message: 'Publish my blog to WordPress'
});
if (!seoAnalysis) {
// Prompt to run SEO analysis first
items.push({
title: 'Next: Run SEO Analysis',
message: 'The blog content is confirmed. Execute analyzeSEO immediately to launch the SEO analysis modal without further prompts.',
priority: 'high'
});
items.push({
title: 'Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
items.push({
title: 'Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
} else if (seoAnalysis && !seoRecommendationsApplied) {
// SEO analysis exists but recommendations not applied yet
items.push({
title: 'Next: Apply SEO Recommendations',
message: 'Open the SEO analysis modal and apply the actionable recommendations right away. Call analyzeSEO to reopen the modal without extra questions.',
priority: 'high'
});
items.push({
title: 'Content Analysis',
message: 'Run analyzeContentQuality to review narrative flow and get final improvement suggestions before publishing.'
});
items.push({
title: '📈 Review SEO Analysis',
message: 'Show me the latest SEO analysis results again by running analyzeSEO.'
});
} else if (seoAnalysis && seoRecommendationsApplied) {
// SEO analysis exists and recommendations applied - show next steps
if (!seoMetadata) {
items.push({
title: 'Next: Generate SEO Metadata',
message: 'SEO recommendations are applied. Execute generateSEOMetadata immediately so we can prepare titles, descriptions, and schema without further prompts.',
priority: 'high'
});
} else {
items.push({
title: 'Next: Publish',
message: 'The blog is SEO-optimized. Use publishToPlatform with your preferred destination (wix|wordpress) right away—no additional confirmation needed.',
priority: 'high'
});
}
items.push({
title: 'Content Analysis',
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
});
items.push({
title: 'Publish',
message: seoMetadata
? 'Publish my blog to your preferred platform using publishToPlatform.'
: 'Generate SEO metadata first, then publish your blog.'
});
if (seoMetadata) {
items.push({
title: '🚀 Publish to Wix',
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".'
});
items.push({
title: '🌐 Publish to WordPress',
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".'
});
}
}
} else {
// No content yet, show generation option
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
@@ -146,11 +212,24 @@ export const useSuggestions = (
}
return items;
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling, hasContent, flowAnalysisCompleted, contentConfirmed]);
}, [
research,
outline,
outlineConfirmed,
researchPolling,
outlinePolling,
mediumPolling,
hasContent,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied
]);
};
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
useSuggestions(research, outline, outlineConfirmed);
useSuggestions({ research, outline, outlineConfirmed });
return null; // This is just a utility component
};

View File

@@ -70,8 +70,21 @@ const BlogSection: React.FC<BlogSectionProps> = ({
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
const currentContent = textarea.value;
const updatedContent = currentContent.replace(originalText, newText);
// For smart suggestions, newText is already the complete updated content with insertion
// For other edits (like text selection improvements), we need to replace originalText with newText
let updatedContent: string;
if (editType === 'smart-suggestion') {
// newText already contains the full content with suggestion inserted
updatedContent = newText;
} else {
// For other edits, replace the selected text
const currentContent = textarea.value;
updatedContent = currentContent.replace(originalText, newText);
}
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
setContent(updatedContent);
// Update parent state
@@ -79,14 +92,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
onContentUpdate([{ id, content: updatedContent }]);
}
// Focus back to textarea and set cursor after the replaced text
setTimeout(() => {
if (contentRef.current) {
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
contentRef.current.focus();
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 100);
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
// For other edits, we may need to handle cursor positioning here if needed
}
}
);

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import TextSelectionMenu from './TextSelectionMenu';
import useSmartTypingAssist from './SmartTypingAssist';
import { debug } from '../../../utils/debug';
interface BlogTextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
@@ -281,12 +282,15 @@ const useBlogTextSelectionHandler = (
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
allSuggestions={smartTypingAssist.allSuggestions}
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
/>
)
};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { debug } from '../../../utils/debug';
interface SmartTypingAssistProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastGeneratedAtRef = useRef<number>(0);
// Quality improvement tracking
const [suggestionStats, setSuggestionStats] = useState({
@@ -52,25 +55,25 @@ const useSmartTypingAssist = (
// Smart typing assist functionality
const generateSmartSuggestion = async (currentText: string) => {
console.log('🔍 [SmartTypingAssist] generateSmartSuggestion called with text length:', currentText.length);
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
if (currentText.length < 20) {
console.log('🔍 [SmartTypingAssist] Text too short for suggestion');
debug.log('[SmartTypingAssist] Text too short for suggestion');
return; // Only suggest after some meaningful content
}
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
debug.log('[SmartTypingAssist] Starting suggestion generation...');
setIsGeneratingSuggestion(true);
try {
// Import the assistive writing API
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
console.log('🔍 [SmartTypingAssist] Calling assistive writing API...');
debug.log('[SmartTypingAssist] Calling assistive writing API...');
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
if (response.success && response.suggestions.length > 0) {
console.log('🔍 [SmartTypingAssist] Received', response.suggestions.length, 'suggestions from API');
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
// Store all suggestions
setAllSuggestions(response.suggestions);
@@ -78,7 +81,7 @@ const useSmartTypingAssist = (
// Show first suggestion
const firstSuggestion = response.suggestions[0];
console.log('🔍 [SmartTypingAssist] Showing first suggestion:', firstSuggestion.text.substring(0, 50) + '...');
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
// Track suggestion shown
setSuggestionStats(prev => ({
@@ -86,12 +89,30 @@ const useSmartTypingAssist = (
totalShown: prev.totalShown + 1
}));
// Get cursor position for suggestion placement
// Get viewport-safe position for suggestion placement
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
const y = Math.max(20, rect.bottom + 10);
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: firstSuggestion.text,
@@ -101,7 +122,7 @@ const useSmartTypingAssist = (
});
}
} else {
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
debug.log('[SmartTypingAssist] No suggestions received from API');
// Fallback to generic suggestions if API fails
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
@@ -116,8 +137,26 @@ const useSmartTypingAssist = (
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: randomSuggestion,
@@ -126,7 +165,7 @@ const useSmartTypingAssist = (
}
}
} catch (error) {
console.error('🔍 [SmartTypingAssist] Failed to generate smart suggestion:', error);
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
// Fallback to generic suggestions on error
const fallbackSuggestions = [
@@ -142,8 +181,14 @@ const useSmartTypingAssist = (
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
const maxWidth = 420;
const maxHeight = 160;
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 5;
if (y > window.innerHeight - maxHeight) {
y = window.innerHeight - (maxHeight + 20);
x = Math.max(20, window.innerWidth - (maxWidth + 20));
}
setSmartSuggestion({
text: randomSuggestion,
@@ -156,7 +201,7 @@ const useSmartTypingAssist = (
};
const handleTypingChange = (newText: string) => {
console.log('🔍 [SmartTypingAssist] handleTypingChange called with text length:', newText.length);
// Not logging this as it fires on every keystroke - too noisy
// Clear existing timeout
if (typingTimeoutRef.current) {
@@ -168,29 +213,45 @@ const useSmartTypingAssist = (
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
console.log('🔍 [SmartTypingAssist] Typing timeout triggered, text length:', newText.length, 'hasShownFirstSuggestion:', hasShownFirstSuggestion);
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
// First time suggestion appears automatically
if (!hasShownFirstSuggestion && newText.length > 20) {
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
const cooldownMs = 15000; // 15s cooldown between suggestions
const now = Date.now();
const sinceLast = now - lastGeneratedAtRef.current;
// First time suggestion appears automatically with sufficient content
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
debug.log('[SmartTypingAssist] Generating first suggestion');
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
lastGeneratedAtRef.current = now;
}
// After first time, only suggest after longer pauses or more content
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
generateSmartSuggestion(newText);
} else {
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
// After first time, show "Continue writing" prompt instead of random suggestions
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
setShowContinueWritingPrompt(true);
}
// Removed verbose log about skipping prompts as it's too noisy
}, 3000); // 3 second pause before suggesting
};
const handleAcceptSuggestion = () => {
if (smartSuggestion && onTextReplace && contentRef.current) {
const element = contentRef.current;
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
const newContent = currentContent + ' ' + smartSuggestion.text;
const element = contentRef.current as HTMLTextAreaElement;
const currentContent = element.value || '';
// Get cursor position
const cursorPosition = element.selectionStart || currentContent.length;
debug.log('[SmartTypingAssist] Cursor position', { cursorPosition, contentLength: currentContent.length });
// Insert suggestion at cursor position
const beforeCursor = currentContent.substring(0, cursorPosition);
const afterCursor = currentContent.substring(cursorPosition);
const suggestionWithSpace = ' ' + smartSuggestion.text + ' ';
const newContent = beforeCursor + suggestionWithSpace + afterCursor;
// Calculate where cursor should be after insertion (right after the suggestion)
const newCursorPosition = cursorPosition + suggestionWithSpace.length;
// Track suggestion accepted
setSuggestionStats(prev => ({
@@ -198,14 +259,21 @@ const useSmartTypingAssist = (
totalAccepted: prev.totalAccepted + 1
}));
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
...suggestionStats,
totalAccepted: suggestionStats.totalAccepted + 1
});
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
// Use the text replacement callback
onTextReplace(currentContent, newContent, 'smart-suggestion');
// Set cursor position after the inserted text
setTimeout(() => {
if (contentRef.current) {
const el = contentRef.current as HTMLTextAreaElement;
el.focus();
el.setSelectionRange(newCursorPosition, newCursorPosition);
debug.log('[SmartTypingAssist] Cursor positioned', { position: newCursorPosition });
}
}, 50);
setSmartSuggestion(null);
}
};
@@ -217,10 +285,7 @@ const useSmartTypingAssist = (
totalRejected: prev.totalRejected + 1
}));
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
...suggestionStats,
totalRejected: suggestionStats.totalRejected + 1
});
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
setSmartSuggestion(null);
setAllSuggestions([]);
@@ -238,11 +303,8 @@ const useSmartTypingAssist = (
totalCycled: prev.totalCycled + 1
}));
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
...suggestionStats,
totalCycled: suggestionStats.totalCycled + 1
});
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
setSuggestionIndex(nextIndex);
setSmartSuggestion(prev => prev ? {
@@ -254,6 +316,25 @@ const useSmartTypingAssist = (
}
};
// Handle "Continue writing" button click
const handleRequestSuggestion = async () => {
if (!contentRef.current) return;
const element = contentRef.current as HTMLTextAreaElement;
const currentContent = element.value || '';
setShowContinueWritingPrompt(false);
if (currentContent.length > 20) {
await generateSmartSuggestion(currentContent);
}
};
// Handle dismissing the "Continue writing" prompt
const handleDismissPrompt = () => {
setShowContinueWritingPrompt(false);
};
// Get suggestion statistics for quality improvement
const getSuggestionStats = () => {
const acceptanceRate = suggestionStats.totalShown > 0
@@ -284,10 +365,13 @@ const useSmartTypingAssist = (
allSuggestions,
suggestionIndex,
suggestionStats: getSuggestionStats(),
showContinueWritingPrompt,
handleTypingChange,
handleAcceptSuggestion,
handleRejectSuggestion,
handleNextSuggestion,
handleRequestSuggestion,
handleDismissPrompt,
getSuggestionStats,
generateSmartSuggestion
};

View File

@@ -34,12 +34,15 @@ interface TextSelectionMenuProps {
}>;
}>;
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
onRejectSuggestion: () => void;
onNextSuggestion: () => void;
onRequestSuggestion: () => void;
onDismissPrompt: () => void;
}
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
isGeneratingSuggestion,
allSuggestions,
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
onRejectSuggestion,
onNextSuggestion
onNextSuggestion,
onRequestSuggestion,
onDismissPrompt
}) => {
return (
<>
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(12px)',
zIndex: 10002,
maxWidth: '400px',
maxWidth: '420px',
minWidth: '320px',
maxHeight: '350px',
overflow: 'auto',
color: 'white'
}}
>
@@ -540,6 +548,93 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
</div>
)}
{/* Continue Writing Prompt */}
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(59, 130, 246, 0.95)',
color: 'white',
padding: '16px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
minWidth: '280px',
maxWidth: '360px'
}}>
<div style={{
fontSize: '13px',
fontWeight: '600',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
AI Writing Assistant
</div>
<div style={{
fontSize: '12px',
opacity: 0.9,
marginBottom: '16px',
lineHeight: '1.5'
}}>
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
</div>
<div style={{
display: 'flex',
gap: '8px'
}}>
<button
onClick={onRequestSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Continue Writing
</button>
<button
onClick={onDismissPrompt}
style={{
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}}
>
</button>
</div>
</div>
)}
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {