story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete

This commit is contained in:
ajaysi
2025-11-13 16:14:26 +05:30
parent 7191c7e7f0
commit 3b9356e2c8
124 changed files with 20055 additions and 1208 deletions

View File

@@ -235,7 +235,14 @@ export const BlogWriter: React.FC = () => {
});
// CopilotKit suggestions management - extracted to useCopilotSuggestions
const hasContent = React.useMemo(() => Object.keys(sections).length > 0, [sections]);
// Check if sections exist AND have actual content (not just empty strings)
const hasContent = React.useMemo(() => {
const sectionKeys = Object.keys(sections);
if (sectionKeys.length === 0) return false;
// Check if at least one section has actual content
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
return sectionsWithContent.length > 0;
}, [sections]);
const {
suggestions,
setSuggestionsRef,

View File

@@ -122,6 +122,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={onTitleSelect}
onCustomTitle={onCustomTitle}
research={research}
/>
<EnhancedOutlineEditor
outline={outline}

View File

@@ -84,10 +84,31 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
// Store current page URL so we can redirect back after OAuth completes
// This MUST be stored before calling handleConnect to ensure it's available after redirect
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
const currentUrl = window.location.href;
// Build the redirect URL to ensure it includes the phase (publish) and works with both localhost and ngrok
const currentPath = window.location.pathname;
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
const currentSearch = window.location.search;
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
// This ensures consistency between where OAuth starts and where callback happens
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const isUsingNgrok = window.location.origin.includes('localhost') ||
window.location.origin.includes('127.0.0.1') ||
window.location.origin === NGROK_ORIGIN;
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
// Build redirect URL with normalized origin
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
try {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', currentUrl);
// Always override any existing redirect URL when connecting from Blog Writer
sessionStorage.setItem('wix_oauth_redirect', redirectUrl);
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
redirectUrl,
currentOrigin: window.location.origin,
redirectOrigin,
isUsingNgrok
});
} catch (e) {
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
}

View File

@@ -1,6 +1,180 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
import { blogWriterCache } from '../../../services/blogWriterCache';
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
if (key === undefined || key === null) {
return;
}
const trimmed = String(key).trim();
if (!trimmed) {
return;
}
const safeContent = content !== undefined && content !== null ? String(content) : '';
map.set(trimmed, safeContent);
map.set(trimmed.toLowerCase(), safeContent);
};
const getIdCandidatesForSection = (section: any, index: number): string[] => {
const rawCandidates = [
section?.id,
section?.section_id,
section?.sectionId,
section?.sectionID,
section?.heading_id,
`section_${index + 1}`,
`Section ${index + 1}`,
`section${index + 1}`,
`s${index + 1}`,
`S${index + 1}`,
`${index + 1}`,
];
const normalized = rawCandidates
.map((value) => (value === undefined || value === null ? '' : String(value).trim()))
.filter(Boolean);
return Array.from(new Set(normalized));
};
const buildExistingContentMap = (sectionsRecord: Record<string, string>): Map<string, string> => {
const map = new Map<string, string>();
if (!sectionsRecord) {
return map;
}
Object.entries(sectionsRecord).forEach(([key, value]) => {
registerContentKey(map, key, value ?? '');
});
return map;
};
const buildResponseContentMaps = (responseSections: any[]): { byId: Map<string, string>; byHeading: Map<string, string> } => {
const byId = new Map<string, string>();
const byHeading = new Map<string, string>();
if (!responseSections) {
return { byId, byHeading };
}
responseSections.forEach((section, index) => {
if (!section) {
return;
}
const content = section?.content;
const normalizedContent = content !== undefined && content !== null ? String(content).trim() : '';
if (!normalizedContent) {
return;
}
registerContentKey(byId, section?.id, normalizedContent);
registerContentKey(byId, section?.section_id, normalizedContent);
registerContentKey(byId, section?.sectionId, normalizedContent);
registerContentKey(byId, section?.sectionID, normalizedContent);
registerContentKey(byId, `section_${index + 1}`, normalizedContent);
registerContentKey(byId, `Section ${index + 1}`, normalizedContent);
registerContentKey(byId, `section${index + 1}`, normalizedContent);
registerContentKey(byId, `s${index + 1}`, normalizedContent);
registerContentKey(byId, `S${index + 1}`, normalizedContent);
registerContentKey(byId, `${index + 1}`, normalizedContent);
const heading = section?.heading || section?.title;
if (heading) {
registerContentKey(byHeading, heading, normalizedContent);
}
});
return { byId, byHeading };
};
const getPrimaryKeyForOutlineSection = (outlineSection: any, index: number): string => {
const candidates = getIdCandidatesForSection(outlineSection, index);
if (candidates.length > 0) {
return candidates[0];
}
const fallbackHeading = outlineSection?.heading || outlineSection?.title;
if (fallbackHeading) {
const trimmed = String(fallbackHeading).trim();
if (trimmed) {
return trimmed;
}
}
return `section_${index + 1}`;
};
const resolveContentForOutlineSection = (
outlineSection: any,
index: number,
responseSections: any[],
responseById: Map<string, string>,
responseByHeading: Map<string, string>,
existingContentMap: Map<string, string>
): { content: string; matchedKey: string } => {
const idCandidates = getIdCandidatesForSection(outlineSection, index);
for (const candidate of idCandidates) {
if (responseById.has(candidate)) {
return { content: responseById.get(candidate) || '', matchedKey: candidate };
}
const lower = candidate.toLowerCase();
if (responseById.has(lower)) {
return { content: responseById.get(lower) || '', matchedKey: candidate };
}
}
const heading = outlineSection?.heading || outlineSection?.title;
if (heading) {
const headingKey = String(heading).trim();
if (headingKey) {
const lowerHeading = headingKey.toLowerCase();
if (responseByHeading.has(lowerHeading)) {
return { content: responseByHeading.get(lowerHeading) || '', matchedKey: headingKey };
}
if (responseByHeading.has(headingKey)) {
return { content: responseByHeading.get(headingKey) || '', matchedKey: headingKey };
}
}
}
const responseSection = responseSections?.[index];
if (responseSection?.content) {
const normalizedContent = String(responseSection.content).trim();
if (normalizedContent) {
return {
content: normalizedContent,
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
};
}
}
for (const candidate of idCandidates) {
if (existingContentMap.has(candidate)) {
return { content: existingContentMap.get(candidate) || '', matchedKey: candidate };
}
const lower = candidate.toLowerCase();
if (existingContentMap.has(lower)) {
return { content: existingContentMap.get(lower) || '', matchedKey: candidate };
}
}
if (heading) {
const headingKey = String(heading).trim();
if (headingKey) {
const lowerHeading = headingKey.toLowerCase();
if (existingContentMap.has(lowerHeading)) {
return { content: existingContentMap.get(lowerHeading) || '', matchedKey: headingKey };
}
if (existingContentMap.has(headingKey)) {
return { content: existingContentMap.get(headingKey) || '', matchedKey: headingKey };
}
}
}
return {
content: '',
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
};
};
interface UseSEOManagerProps {
sections: Record<string, string>;
@@ -47,8 +221,34 @@ export const useSEOManager = ({
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
// Check if sections have actual content (not just empty strings)
let sectionsWithContent = hasSections ? Object.values(sections).filter(c => c && c.trim().length > 0) : [];
let hasValidContent = sectionsWithContent.length > 0;
// If sections don't exist in state, check cache (similar to how content generation checks cache)
if (!hasValidContent && outline && outline.length > 0) {
try {
const outlineIds = outline.map(s => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent && Object.keys(cachedContent).length > 0) {
sectionsWithContent = Object.values(cachedContent).filter(c => c && c.trim().length > 0);
hasValidContent = sectionsWithContent.length > 0;
if (hasValidContent) {
debug.log('[BlogWriter] Using cached content for SEO analysis', { sections: Object.keys(cachedContent).length });
// Update sections state with cached content
setSections(cachedContent);
}
}
} catch (e) {
debug.log('[BlogWriter] Error checking cache for SEO analysis', e);
}
}
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasValidContent) {
return "No blog content available for SEO analysis. Please generate content first. Content generation may still be in progress - please wait for it to complete.";
}
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
@@ -69,7 +269,7 @@ export const useSEOManager = ({
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.";
}, [sections, research, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed]);
}, [sections, research, outline, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed, setSections]);
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
@@ -78,11 +278,29 @@ export const useSEOManager = ({
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 existingContentMap = buildExistingContentMap(sections || {});
const emptyMap = new Map<string, string>();
const sectionPayload = outline.map((section, index) => {
const existingMatch = resolveContentForOutlineSection(
section,
index,
[],
emptyMap,
emptyMap,
existingContentMap
);
const payloadContentRaw = existingMatch.content ?? sections?.[section?.id] ?? '';
const payloadContent = payloadContentRaw !== undefined && payloadContentRaw !== null ? String(payloadContentRaw) : '';
const rawIdentifier = section?.id || section?.section_id || section?.sectionId || section?.sectionID || `section_${index + 1}`;
const identifier = String(rawIdentifier).trim();
return {
id: identifier,
heading: section.heading,
content: payloadContent,
};
});
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
@@ -100,43 +318,59 @@ export const useSEOManager = ({
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;
}
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
const normalizedSections: Record<string, string> = {};
const sectionKeysForCache: string[] = [];
outline.forEach((section, index) => {
const { content: resolvedContent, matchedKey } = resolveContentForOutlineSection(
section,
index,
response.sections,
responseById,
responseByHeading,
existingContentMap
);
const finalContent = (resolvedContent ?? '').trim();
const contentToUse = finalContent || '';
const primaryKey = getPrimaryKeyForOutlineSection(section, index);
normalizedSections[primaryKey] = contentToUse;
sectionKeysForCache.push(primaryKey);
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
const uniqueSectionKeys = Array.from(new Set(sectionKeysForCache));
if (uniqueSectionKeys.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);
const sectionsWithContent = Object.values(normalizedSections).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,
debug.log('[BlogWriter] Applied SEO recommendations: sections normalized', {
sectionCount: uniqueSectionKeys.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
sectionKeys: uniqueSectionKeys,
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
setSections(normalizedSections);
try {
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
} catch (cacheError) {
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
}
// 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);
@@ -154,7 +388,7 @@ export const useSEOManager = ({
// 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') {
@@ -163,7 +397,7 @@ export const useSEOManager = ({
} 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]);
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage, blogWriterApi } from '../../services/blogWriterApi';
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
@@ -38,6 +38,9 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
key_points: '',
target_words: 300
});
const [showRefineModal, setShowRefineModal] = useState(false);
const [refineFeedback, setRefineFeedback] = useState('');
const [isRefining, setIsRefining] = useState(false);
const toggleExpanded = (sectionId: string) => {
const newExpanded = new Set(expandedSections);
@@ -89,12 +92,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
}
};
const handleRefineOutline = async () => {
if (!refineFeedback.trim()) {
alert('Please provide feedback on how you would like to refine the outline.');
return;
}
setIsRefining(true);
try {
// Use the parent's onRefine callback which handles the API call and state update
// The callback expects: operation, sectionId, payload
await onRefine('refine', undefined, { feedback: refineFeedback.trim() });
setRefineFeedback('');
setShowRefineModal(false);
// Show success message
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
background-color: #4caf50;
color: white;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
`;
toast.textContent = '✅ Outline refined successfully!';
document.body.appendChild(toast);
setTimeout(() => document.body.removeChild(toast), 3000);
} catch (error) {
console.error('Failed to refine outline:', error);
alert('Failed to refine outline. Please try again.');
} finally {
setIsRefining(false);
}
};
const getTotalWords = () => {
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
};
return (
<>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
@@ -153,24 +197,45 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
/>
</div>
</div>
<button
onClick={() => setShowAddSection(!showAddSection)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
Add Section
</button>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<button
onClick={() => setShowRefineModal(true)}
style={{
backgroundColor: '#7b1fa2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
title="Refine the outline structure based on your feedback"
>
🔧 Refine Outline
</button>
<button
onClick={() => setShowAddSection(!showAddSection)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
Add Section
</button>
</div>
</div>
</div>
@@ -656,6 +721,120 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
</div>
</div>
{/* Refine Outline Modal */}
{showRefineModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '32px',
maxWidth: '600px',
width: '90%',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb'
}}>
<div style={{ marginBottom: '24px' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
🔧 Refine Outline
</h2>
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
Provide feedback on how you'd like to improve the outline structure
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: '500',
color: '#333'
}}>
Your Feedback
</label>
<textarea
value={refineFeedback}
onChange={(e) => setRefineFeedback(e.target.value)}
placeholder="E.g., Add a section about best practices, merge sections 2 and 3, expand the introduction..."
style={{
width: '100%',
minHeight: '120px',
padding: '12px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setShowRefineModal(false);
setRefineFeedback('');
}}
disabled={isRefining}
style={{
padding: '10px 20px',
backgroundColor: '#f5f5f5',
color: '#666',
border: 'none',
borderRadius: '8px',
cursor: isRefining ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Cancel
</button>
<button
onClick={handleRefineOutline}
disabled={isRefining || !refineFeedback.trim()}
style={{
padding: '10px 20px',
backgroundColor: isRefining || !refineFeedback.trim() ? '#9ca3af' : '#7b1fa2',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: isRefining || !refineFeedback.trim() ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{isRefining ? (
<>
<span></span>
<span>Refining...</span>
</>
) : (
<>
<span>🔧</span>
<span>Refine Outline</span>
</>
)}
</button>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { BlogOutlineSection } from '../../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../services/blogWriterApi';
interface EnhancedTitleSelectorProps {
titleOptions: string[];
@@ -9,6 +9,8 @@ interface EnhancedTitleSelectorProps {
sections: BlogOutlineSection[];
researchTitles?: string[];
aiGeneratedTitles?: string[];
research?: BlogResearchResponse;
onTitlesGenerated?: (titles: string[]) => void;
}
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
@@ -18,10 +20,15 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
onCustomTitle,
sections,
researchTitles = [],
aiGeneratedTitles = []
aiGeneratedTitles = [],
research,
onTitlesGenerated
}) => {
const [showModal, setShowModal] = useState(false);
const [customTitle, setCustomTitle] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
const [generationProgress, setGenerationProgress] = useState<string>('');
const handleTitleSelect = (title: string) => {
onTitleSelect(title);
@@ -36,6 +43,57 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
}
};
const handleGenerateSEOTitles = async () => {
if (!research || !sections.length || isGenerating) {
return;
}
setIsGenerating(true);
setGenerationProgress('Analyzing research data and outline structure...');
try {
const keywordAnalysis = research.keyword_analysis || {};
const primaryKeywords = keywordAnalysis.primary || [];
const secondaryKeywords = keywordAnalysis.secondary || [];
const contentAngles = research.suggested_angles || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Simulate progress updates
setTimeout(() => setGenerationProgress('Extracting keywords and content angles...'), 500);
setTimeout(() => setGenerationProgress('Generating SEO-optimized titles with AI...'), 1500);
const result = await blogWriterApi.generateSEOTitles({
research,
outline: sections,
primary_keywords: primaryKeywords,
secondary_keywords: secondaryKeywords,
content_angles: contentAngles,
search_intent: searchIntent,
word_count: sections.reduce((sum, s) => sum + (s.target_words || 0), 0)
});
setGenerationProgress('Finalizing titles...');
if (result.success && result.titles) {
setTimeout(() => {
setGeneratedTitles(result.titles);
setGenerationProgress('');
if (onTitlesGenerated) {
onTitlesGenerated(result.titles);
}
}, 500);
}
} catch (error) {
console.error('Failed to generate SEO titles:', error);
setGenerationProgress('');
alert('Failed to generate SEO titles. Please try again.');
} finally {
setTimeout(() => {
setIsGenerating(false);
}, 1000);
}
};
const getSectionSummary = () => {
return sections.map(section => ({
title: section.heading,
@@ -66,35 +124,39 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
margin: '0',
color: '#666',
fontSize: '14px',
wordBreak: 'break-word',
lineHeight: '1.4',
maxHeight: '60px',
whiteSpace: 'nowrap',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
textOverflow: 'ellipsis',
maxWidth: '600px'
}}>
{selectedTitle || 'No title selected'}
{(selectedTitle || 'No title selected').length > 150
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
: (selectedTitle || 'No title selected')}
</p>
</div>
<button
onClick={() => setShowModal(true)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
ALwrity it
</button>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowModal(true)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px',
position: 'relative'
}}
title="Open title suggestions. Click 'Generate 5 SEO-Optimized Titles' in the modal to create premium titles (50-65 characters) optimized for search engines using your research data and outline."
>
ALwrity it
</button>
</div>
</div>
</div>
@@ -165,63 +227,163 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
</button>
</div>
{/* Section Information */}
<div style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
📋 Current Outline Summary
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
{/* Generate SEO Titles Button */}
{research && sections.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<button
onClick={handleGenerateSEOTitles}
disabled={isGenerating}
style={{
width: '100%',
padding: '14px 24px',
backgroundColor: isGenerating ? '#9ca3af' : '#1976d2',
color: 'white',
border: 'none',
borderRadius: '12px',
cursor: isGenerating ? 'not-allowed' : 'pointer',
fontSize: '15px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.2s ease',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (!isGenerating) {
e.currentTarget.style.backgroundColor = '#1565c0';
}
}}
onMouseLeave={(e) => {
if (!isGenerating) {
e.currentTarget.style.backgroundColor = '#1976d2';
}
}}
>
{isGenerating ? (
<>
<span></span>
<span>{generationProgress || 'Generating SEO Titles...'}</span>
</>
) : (
<>
<span></span>
<span>Generate 5 SEO-Optimized Titles</span>
</>
)}
</button>
{isGenerating && (
<div style={{
width: '100%',
height: '4px',
backgroundColor: '#e5e7eb',
borderRadius: '2px',
marginTop: '12px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
backgroundColor: '#1976d2',
borderRadius: '2px',
animation: 'pulse 1.5s ease-in-out infinite',
width: '100%'
}} />
</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
</div>
</div>
)}
{isGenerating && generationProgress && (
<p style={{
margin: '8px 0 0 0',
color: '#6b7280',
fontSize: '13px',
textAlign: 'center'
}}>
{generationProgress}
</p>
)}
</div>
{/* Section Details */}
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
<div style={{ display: 'grid', gap: '8px' }}>
{sectionSummary.map((section, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #e0e0e0'
}}>
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
<span>{section.wordCount} words</span>
<span>{section.subheadings} subheadings</span>
<span>{section.keyPoints} key points</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Title Options */}
<div style={{ display: 'grid', gap: '24px' }}>
{/* Generated SEO Titles */}
{generatedTitles.length > 0 && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#dcfce7',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
}}>
🎯
</div>
<div>
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
SEO-Optimized Titles
</h4>
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
Premium titles optimized for search engines (50-65 characters)
</p>
</div>
<span style={{
fontSize: '12px',
backgroundColor: '#16a34a',
color: 'white',
padding: '4px 12px',
borderRadius: '16px',
fontWeight: '500'
}}>
{generatedTitles.length}
</span>
</div>
<div style={{ display: 'grid', gap: '10px' }}>
{generatedTitles.map((title, index) => (
<button
key={`seo-${index}`}
onClick={() => handleTitleSelect(title)}
style={{
width: '100%',
padding: '16px 20px',
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
borderRadius: '12px',
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
cursor: 'pointer',
textAlign: 'left',
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
e.currentTarget.style.backgroundColor = '#f9fafb';
e.currentTarget.style.borderColor = '#d1d5db';
}
}}
onMouseLeave={(e) => {
if (selectedTitle !== title) {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.borderColor = '#e5e7eb';
}
}}
title={title}
>
{title}
</button>
))}
</div>
</div>
)}
{/* Research Content Angles */}
{researchTitles.length > 0 && (
<div>
@@ -274,7 +436,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
wordBreak: 'break-word'
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -348,7 +512,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
wordBreak: 'break-word'
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -452,6 +618,61 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
</div>
</div>
{/* Section Information */}
<div style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
padding: '16px',
marginTop: '24px'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
📋 Current Outline Summary
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
</div>
</div>
</div>
{/* Section Details */}
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
<div style={{ display: 'grid', gap: '8px' }}>
{sectionSummary.map((section, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #e0e0e0'
}}>
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
<span>{section.wordCount} words</span>
<span>{section.subheadings} subheadings</span>
<span>{section.keyPoints} key points</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Modal Footer */}
<div style={{
display: 'flex',

View File

@@ -31,7 +31,11 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
console.info('[ResearchAction] ✅ Research completed (onComplete callback)', {
hasResult: !!result,
resultKeys: result ? Object.keys(result) : [],
status: polling.currentStatus
});
if (result && result.keywords) {
researchCache.cacheResult(
@@ -45,7 +49,10 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
// Reset navigation tracking when research completes
hasNavigatedRef.current = false;
// Call parent callback first
onResearchComplete?.(result);
// Close modal immediately when research completes
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
@@ -60,26 +67,47 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
// Close modal when research completes (status becomes 'completed' or polling stops with result)
// Set of statuses that indicate successful completion
const COMPLETED_STATUSES = React.useMemo(
() => new Set(['completed', 'success', 'succeeded', 'finished']),
[]
);
// Close modal when research completes (status becomes a completed state or polling stops with a result)
useEffect(() => {
if (showProgressModal && (
polling.currentStatus === 'completed' ||
(!polling.isPolling && polling.result && polling.currentStatus !== 'failed')
)) {
const normalizedStatus = (polling.currentStatus || '').toLowerCase();
const isCompleted = COMPLETED_STATUSES.has(normalizedStatus);
// Check if we have a result (indicates completion even if status isn't updated yet)
const hasResult = !!polling.result;
// Check if polling stopped and we have a result, or status indicates completion
const shouldClose = showProgressModal && (
isCompleted ||
(hasResult && normalizedStatus !== 'failed') ||
(!polling.isPolling && hasResult && normalizedStatus !== 'failed')
);
if (shouldClose) {
console.info('[ResearchAction] Closing modal - research completed', {
status: polling.currentStatus,
isPolling: polling.isPolling,
hasResult: !!polling.result
hasResult: hasResult,
normalizedStatus: normalizedStatus,
isCompleted: isCompleted
});
// Small delay to show completion message before closing
const timer = setTimeout(() => {
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}, 500);
return () => clearTimeout(timer);
// Close modal immediately when research completes
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}
}, [polling.currentStatus, polling.isPolling, polling.result, showProgressModal]);
}, [
COMPLETED_STATUSES,
polling.currentStatus,
polling.isPolling,
polling.result,
showProgressModal
]);
useCopilotActionTyped({
name: 'showResearchForm',
@@ -256,7 +284,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal && polling.currentStatus !== 'completed'}
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
interface ResearchProgressModalProps {
open: boolean;
@@ -9,6 +9,269 @@ interface ResearchProgressModalProps {
onClose: () => void;
}
type Tone = 'info' | 'active' | 'success' | 'warning' | 'error';
type StageState = 'upcoming' | 'active' | 'done' | 'error';
const statusThemes: Record<
string,
{ label: string; description: string; color: string; background: string }
> = {
pending: {
label: 'Queued',
description: 'Preparing the research workflow…',
color: '#1f2937',
background: '#e5e7eb'
},
running: {
label: 'In Progress',
description: 'Gathering sources and extracting insights.',
color: '#1d4ed8',
background: '#dbeafe'
},
completed: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
success: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
succeeded: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
finished: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
failed: {
label: 'Needs Attention',
description: 'We hit an issue while running research.',
color: '#b91c1c',
background: '#fee2e2'
}
};
const toneStyles: Record<Tone, { bg: string; border: string; text: string }> = {
info: { bg: '#f8fafc', border: '#e2e8f0', text: '#0f172a' },
active: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
success: { bg: '#ecfdf5', border: '#bbf7d0', text: '#047857' },
warning: { bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' },
error: { bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
};
const stageDefinitions = [
{
id: 'cache',
label: 'Cache Check',
description: 'Looking for saved research results to speed things up.',
icon: '🗂️',
keywords: ['cache', 'cached', 'stored']
},
{
id: 'discovery',
label: 'Source Discovery',
description: 'Exploring trusted sources across the web.',
icon: '🔎',
keywords: ['search', 'source', 'gather', 'google', 'discover']
},
{
id: 'analysis',
label: 'Insight Extraction',
description: 'Extracting data points, statistics, and quotes.',
icon: '🧠',
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
},
{
id: 'assembly',
label: 'Structuring Findings',
description: 'Packaging insights and preparing summaries.',
icon: '📝',
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
}
] as const;
type StageId = (typeof stageDefinitions)[number]['id'];
interface MessageMeta {
timestamp: string;
timeLabel: string;
raw: string;
title: string;
subtitle?: string;
icon: string;
tone: Tone;
stage: StageId | null;
}
const completionStatuses = new Set(['completed', 'success', 'succeeded', 'finished']);
const formatTime = (timestamp: string) => {
try {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(new Date(timestamp));
} catch {
return timestamp;
}
};
const inferStage = (text: string): StageId | null => {
const lower = text.toLowerCase();
for (const stage of stageDefinitions) {
if (stage.keywords.some(keyword => lower.includes(keyword))) {
return stage.id;
}
}
return null;
};
const friendlyMappings: Array<{
keywords: string[];
title: string;
subtitle?: string;
icon: string;
tone: Tone;
stage?: StageId;
}> = [
{
keywords: ['checking cache', 'cache'],
title: 'Checking existing research cache',
subtitle: 'Looking for previously generated insights so we can respond instantly.',
icon: '🗂️',
tone: 'info',
stage: 'cache'
},
{
keywords: ['found cached research', 'loading cached'],
title: 'Loaded cached research results',
subtitle: 'Serving saved insights to keep things fast.',
icon: '⚡',
tone: 'success',
stage: 'cache'
},
{
keywords: ['starting research'],
title: 'Launching fresh research',
subtitle: 'Bootstrapping the workflow and validating your request.',
icon: '🚀',
tone: 'active',
stage: 'discovery'
},
{
keywords: ['search', 'query', 'sources', 'web'],
title: 'Collecting authoritative sources',
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
icon: '🔎',
tone: 'active',
stage: 'discovery'
},
{
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
title: 'Extracting key insights',
subtitle: 'Summarising statistics, trends, and quotes that matter.',
icon: '🧠',
tone: 'active',
stage: 'analysis'
},
{
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
title: 'Structuring the research package',
subtitle: 'Organising findings into ready-to-use sections.',
icon: '🧩',
tone: 'info',
stage: 'assembly'
},
{
keywords: ['completed successfully', 'research completed', 'ready'],
title: 'Research completed successfully',
subtitle: 'All insights are ready for the outline phase.',
icon: '✅',
tone: 'success',
stage: 'assembly'
},
{
keywords: ['failed', 'error', 'limit exceeded'],
title: 'Research encountered an issue',
subtitle: 'Review the error message below and try again.',
icon: '⚠️',
tone: 'error'
}
];
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
const raw = message.message || '';
const lower = raw.toLowerCase();
const mapping = friendlyMappings.find(entry =>
entry.keywords.some(keyword => lower.includes(keyword))
);
if (mapping) {
return {
timestamp: message.timestamp,
timeLabel: formatTime(message.timestamp),
raw,
title: mapping.title,
subtitle: mapping.subtitle,
icon: mapping.icon,
tone: mapping.tone,
stage: mapping.stage ?? inferStage(raw)
};
}
const stage = inferStage(raw);
return {
timestamp: message.timestamp,
timeLabel: formatTime(message.timestamp),
raw,
title: sanitizeTitle(raw) || 'Update received',
icon: '📝',
tone: 'info',
stage
};
};
const stageStateCopy: Record<StageState, { label: string; color: string; background: string; border: string }> = {
upcoming: {
label: 'Pending',
color: '#6b7280',
background: '#f3f4f6',
border: '#e5e7eb'
},
active: {
label: 'In Progress',
color: '#2563eb',
background: '#eff6ff',
border: '#bfdbfe'
},
done: {
label: 'Completed',
color: '#047857',
background: '#ecfdf5',
border: '#bbf7d0'
},
error: {
label: 'Needs Attention',
color: '#b91c1c',
background: '#fee2e2',
border: '#fecaca'
}
};
const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
open,
title = 'Research in progress',
@@ -17,63 +280,176 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
error,
onClose
}) => {
if (!open) return null;
const scrollRef = useRef<HTMLDivElement | null>(null);
const normalizedStatus = (status || '').toLowerCase();
const statusKey = error ? 'failed' : normalizedStatus;
const statusInfo = statusThemes[statusKey] || statusThemes.pending;
const processedMessages = useMemo(() => {
if (!messages || messages.length === 0) {
return [] as MessageMeta[];
}
return messages.map(mapMessageToMeta);
}, [messages]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [processedMessages.length]);
const latestMessage = processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null;
const stagesWithState = useMemo(() => {
const states: StageState[] = stageDefinitions.map(() => 'upcoming');
let highestCompletedIndex = -1;
processedMessages.forEach(meta => {
if (!meta.stage) {
return;
}
const idx = stageDefinitions.findIndex(stage => stage.id === meta.stage);
if (idx === -1) {
return;
}
if (meta.tone === 'error' || /error|failed/i.test(meta.raw)) {
states[idx] = 'error';
} else {
states[idx] = 'done';
if (idx > highestCompletedIndex) {
highestCompletedIndex = idx;
}
}
});
if (!error) {
const firstPending = states.findIndex(state => state === 'upcoming');
if (firstPending !== -1 && !completionStatuses.has(normalizedStatus)) {
states[firstPending] = 'active';
} else if (completionStatuses.has(normalizedStatus)) {
for (let i = 0; i < states.length; i += 1) {
if (states[i] !== 'error') {
states[i] = 'done';
}
}
}
} else if (highestCompletedIndex >= 0) {
states[highestCompletedIndex] = 'error';
}
return stageDefinitions.map((stage, index) => ({
...stage,
state: states[index]
}));
}, [error, normalizedStatus, processedMessages]);
if (!open) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}>
<div style={{
width: '92%',
maxWidth: 900,
maxHeight: '82vh',
background: 'white',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(0,0,0,0.3)',
border: '1px solid #e5e7eb'
}}>
{/* Header with background illustration */}
<div style={{
position: 'relative',
padding: '28px 28px 24px 28px',
background: '#f8fafc'
}}>
<div style={{
position: 'absolute',
inset: 0,
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left center',
backgroundSize: '38% auto',
opacity: 0.12
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="research-progress-title"
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
padding: '24px'
}}
>
<div
style={{
width: '100%',
maxWidth: 940,
maxHeight: '82vh',
background: '#ffffff',
borderRadius: 18,
boxShadow: '0 28px 80px rgba(15, 23, 42, 0.25)',
border: '1px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<div
style={{
padding: '28px 32px 24px 32px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
position: 'relative'
}}
>
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left center',
backgroundSize: '35% auto',
opacity: 0.12,
pointerEvents: 'none'
}}
/>
<div
style={{
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 20, color: '#111827' }}>{title}</h3>
<p style={{ margin: '6px 0 0 0', color: '#6b7280', fontSize: 13 }}>We are gathering sources, extracting insights, and preparing highquality research.</p>
{status && (
<div style={{ marginTop: 8, fontSize: 12, color: '#374151' }}>Status: {status}</div>
)}
<h3 id="research-progress-title" style={{ margin: 0, fontSize: 22, color: '#0f172a' }}>
{title}
</h3>
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
</p>
<div
style={{
marginTop: 14,
display: 'inline-flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderRadius: 999,
background: statusInfo.background,
color: statusInfo.color,
fontSize: 13,
fontWeight: 600,
border: `1px solid ${statusInfo.color}1A`
}}
>
<span>{statusInfo.label}</span>
<span style={{ fontSize: 12, color: '#475569', fontWeight: 500 }}>{statusInfo.description}</span>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: '1px solid #e5e7eb',
borderRadius: 10,
padding: '8px 12px',
background: '#ffffff',
border: '1px solid #cbd5f5',
borderRadius: 12,
padding: '10px 14px',
cursor: 'pointer',
color: '#374151'
fontSize: 13,
fontWeight: 600,
color: '#1f2937',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.08)',
transition: 'all 0.2s ease'
}}
>
Close
@@ -81,29 +457,157 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
</div>
</div>
{/* Messages list */}
<div style={{ padding: 20 }}>
<div style={{
border: '1px solid #e5e7eb',
borderRadius: 12,
overflow: 'hidden',
background: '#ffffff'
}}>
<div style={{ maxHeight: '48vh', overflowY: 'auto' }}>
{messages.length === 0 && (
<div style={{ padding: 16, color: '#6b7280', fontSize: 14 }}>Awaiting progress updates</div>
)}
{messages.map((m, idx) => (
<div key={idx} style={{ display: 'flex', gap: 12, padding: '12px 16px', borderTop: idx === 0 ? 'none' : '1px solid #f3f4f6' }}>
<div style={{ color: '#9ca3af', minWidth: 120, fontSize: 12 }}>{new Date(m.timestamp).toLocaleTimeString()}</div>
<div style={{ color: '#374151', fontSize: 14 }}>{m.message}</div>
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
marginBottom: 20
}}
>
{stagesWithState.map(stage => {
const copy = stageStateCopy[stage.state];
return (
<div
key={stage.id}
style={{
flex: '1 1 180px',
minWidth: 180,
borderRadius: 14,
padding: '14px 16px',
background: copy.background,
border: `1px solid ${copy.border}`,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600, color: '#0f172a' }}>
<span style={{ fontSize: 22 }}>{stage.icon}</span>
<span>{stage.label}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12.5, color: '#475569' }}>{stage.description}</div>
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color }}>{copy.label}</div>
</div>
))}
);
})}
</div>
{latestMessage && (
<div
style={{
borderRadius: 16,
padding: '18px 20px',
border: `1px solid ${toneStyles[latestMessage.tone].border}`,
background: toneStyles[latestMessage.tone].bg,
marginBottom: 20,
boxShadow: '0 4px 16px rgba(15, 23, 42, 0.08)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<div style={{ fontSize: 28 }}>{latestMessage.icon}</div>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 16
}}
>
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a' }}>{latestMessage.title}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{latestMessage.timeLabel}</div>
</div>
{latestMessage.subtitle && (
<div style={{ marginTop: 6, fontSize: 13.5, color: '#334155' }}>{latestMessage.subtitle}</div>
)}
{latestMessage.raw && (
<div style={{ marginTop: 10, fontSize: 12.5, color: '#64748b' }}>{latestMessage.raw}</div>
)}
</div>
</div>
</div>
)}
<div
style={{
border: '1px solid #e2e8f0',
borderRadius: 16,
padding: '18px 0',
maxHeight: '32vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
<div
ref={scrollRef}
style={{
overflowY: 'auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
gap: 12
}}
>
{processedMessages.length === 0 && (
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14 }}>
Awaiting progress updates
</div>
)}
{processedMessages.map((meta, index) => {
const styles = toneStyles[meta.tone];
return (
<div
key={`${meta.timestamp}-${index}`}
style={{
display: 'flex',
gap: 14,
padding: '12px 14px',
borderRadius: 12,
background: styles.bg,
border: `1px solid ${styles.border}`
}}
>
<div style={{ fontSize: 22 }}>{meta.icon}</div>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 12
}}
>
<div style={{ fontWeight: 600, color: styles.text, fontSize: 14 }}>{meta.title}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{meta.timeLabel}</div>
</div>
{meta.subtitle && (
<div style={{ marginTop: 4, fontSize: 13, color: '#475569' }}>{meta.subtitle}</div>
)}
{meta.raw && (
<div style={{ marginTop: 6, fontSize: 12.5, color: '#6b7280' }}>{meta.raw}</div>
)}
</div>
</div>
);
})}
</div>
</div>
{error && (
<div style={{ marginTop: 12, color: '#b91c1c', fontSize: 13 }}>Error: {error}</div>
<div
style={{
marginTop: 18,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid #fecaca',
background: '#fef2f2',
color: '#b91c1c',
fontSize: 13.5
}}
>
Error: {error}
</div>
)}
</div>
</div>
@@ -113,4 +617,3 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
export default ResearchProgressModal;

View File

@@ -191,29 +191,72 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}, [isOpen, blogContent?.length, researchData]);
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
// Prevent multiple simultaneous calls
if (isAnalyzing && !forceRefresh) {
console.log('⏸️ SEO analysis already in progress, skipping duplicate call');
return;
}
try {
setIsAnalyzing(true);
setError(null);
setProgress(0);
setProgressMessage('Starting SEO analysis...');
setProgressMessage('Checking cache for previous SEO analysis...');
// Cache check
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
// Cache check - always check cache first unless force refresh is requested
// Compute hash if not already available
let hash = contentHash;
if (!hash) {
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
// Update state for future use
setContentHash(hash);
}
const cacheKey = getSeoCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO cache', {
cacheKey,
hasHash: !!hash,
forceRefresh,
hashLength: hash?.length,
titleLength: blogTitle?.length,
contentLength: blogContent?.length
});
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);
try {
const parsed = JSON.parse(cached) as SEOAnalysisResult;
// Validate cached data has required fields
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
setAnalysisResult(parsed);
setIsAnalyzing(false);
setProgress(100);
setProgressMessage('SEO analysis loaded from cache');
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed);
}
return;
} else {
console.warn('⚠️ Cached SEO analysis data is invalid, will fetch fresh analysis');
}
} catch (parseError) {
console.warn('⚠️ Failed to parse cached SEO analysis, will fetch fresh analysis', parseError);
// Remove invalid cache entry
if (typeof window !== 'undefined') {
window.localStorage.removeItem(cacheKey);
}
}
return;
} else {
console.log(' No cached SEO analysis found, will fetch from API', { cacheKey });
}
} else {
console.log('🔄 Force refresh requested, skipping cache check');
}
setProgressMessage('Starting SEO analysis...');
// Simulated progress
const progressStages = [
{ progress: 20, message: 'Extracting keywords from research data...' },
@@ -297,14 +340,17 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
setAnalysisResult(convertedResult);
// Save to cache
// Save to cache - use the same cacheKey that was used for checking
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));
// Use the same hash and cacheKey from the cache check section
// This ensures consistency between cache check and save
if (typeof window !== 'undefined' && cacheKey) {
window.localStorage.setItem(cacheKey, JSON.stringify(convertedResult));
console.log('💾 SEO analysis cached', { cacheKey, overall_score: convertedResult.overall_score });
}
} catch {}
} catch (cacheError) {
console.warn('⚠️ Failed to cache SEO analysis', cacheError);
}
setIsAnalyzing(false);
@@ -340,21 +386,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
// Precompute hash when modal opens
// Precompute hash when modal opens and trigger cache check
// Use a ref to prevent multiple simultaneous calls
const hasRunAnalysisRef = React.useRef(false);
useEffect(() => {
if (isOpen) {
if (isOpen && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
// After hash is computed, check cache if we don't have analysis result yet
if (!analysisResult) {
// Small delay to ensure hash is set in state
setTimeout(() => {
runSEOAnalysis();
}, 100);
}
})();
} else if (!isOpen) {
// Reset hash and flag when modal closes
setContentHash('');
hasRunAnalysisRef.current = false;
}
}, [isOpen, blogContent, blogTitle]);
}, [isOpen, blogContent, blogTitle, analysisResult, runSEOAnalysis]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !analysisResult) {
if (isOpen && !analysisResult && contentHash && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
}, [isOpen, analysisResult, contentHash, runSEOAnalysis]);
const getScoreColor = (score: number) => {
if (score >= 80) return 'success.main';

View File

@@ -146,19 +146,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}, [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);
@@ -169,10 +156,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
// Calculate content hash for caching
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(hash);
// Calculate content hash for caching - use existing hash if available
let hash = contentHash;
if (!hash) {
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
// Update state for future use
setContentHash(hash);
}
const cacheKey = getMetadataCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO metadata cache', { cacheKey, hasHash: !!hash, forceRefresh });
// Check cache first (unless force refresh)
if (!forceRefresh && typeof window !== 'undefined') {
@@ -180,15 +172,32 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
if (cached) {
try {
const parsed = JSON.parse(cached) as SEOMetadataResult;
console.log('✅ Using cached SEO metadata');
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
return;
// Validate cached data has required fields
if (parsed && parsed.success !== undefined) {
console.log('✅ Using cached SEO metadata', { cacheKey, success: parsed.success });
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
// Notify parent that metadata is available
if (onMetadataGenerated) {
onMetadataGenerated(parsed);
}
return;
} else {
console.warn('⚠️ Cached SEO metadata data is invalid, will fetch fresh metadata');
}
} catch (e) {
console.warn('Failed to parse cached metadata:', e);
console.warn('⚠️ Failed to parse cached SEO metadata, will fetch fresh metadata', e);
// Remove invalid cache entry
if (typeof window !== 'undefined') {
window.localStorage.removeItem(cacheKey);
}
}
} else {
console.log(' No cached SEO metadata found, will fetch from API', { cacheKey });
}
} else {
console.log('🔄 Force refresh requested, skipping cache check');
}
// Make API call to generate metadata
@@ -203,7 +212,43 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const result = response.data;
console.log('✅ SEO metadata generation response:', result);
if (!result.success) {
// Check if the response indicates a subscription error (even if HTTP status is 200)
if (!result.success && result.error) {
const errorMessage = result.error;
// Check if error message indicates subscription limit (429/402)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription')) {
console.log('SEOMetadataModal: Detected subscription error in response data', {
error: errorMessage,
data: result
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429, // Treat as 429 for subscription error
data: {
error: errorMessage,
message: result.message || errorMessage,
provider: result.provider || 'unknown',
usage_info: result.usage_info || {}
}
}
};
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
setIsGenerating(false);
return;
} else {
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
}
}
// If not a subscription error, throw the error normally
throw new Error(result.error || 'Metadata generation failed');
}
@@ -226,15 +271,51 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
const errorMessage = err?.message || err?.response?.data?.error || '';
// Check HTTP status code first
if (status === 429 || status === 402) {
console.log('SEOMetadataModal: Detected subscription error, triggering global handler', {
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
status,
data: err?.response?.data
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsGenerating(false);
return;
} else {
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
}
}
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription') ||
errorMessage.includes('429')) {
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
errorMessage,
err
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429,
data: {
error: errorMessage,
message: errorMessage,
provider: err?.response?.data?.provider || 'unknown',
usage_info: err?.response?.data?.usage_info || {}
}
}
};
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
setIsGenerating(false);
return;
} else {
@@ -247,7 +328,34 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} finally {
setIsGenerating(false);
}
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
// Precompute hash when modal opens and trigger cache check
useEffect(() => {
if (isOpen) {
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
// After hash is computed, check cache if we don't have metadata result yet
if (!metadataResult) {
// Small delay to ensure hash is set in state
setTimeout(() => {
generateMetadata(false);
}, 100);
}
})();
} else {
// Reset hash when modal closes
setContentHash('');
}
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !metadataResult && contentHash) {
generateMetadata(false);
}
}, [isOpen, metadataResult, contentHash, generateMetadata]);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);

View File

@@ -123,7 +123,10 @@ export const useSuggestions = ({
});
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation and optimization
if (hasContent && !contentConfirmed) {
// Follow the same pattern as research/outline phases - show suggestions based on state
// Don't block on hasContent check - let the actions handle validation
if (!contentConfirmed) {
// Content exists but not confirmed yet - show options to work with content
items.push({
title: '🔄 ReWrite Blog',
message: 'I want to rewrite my blog with different approach, tone, or focus'
@@ -136,7 +139,8 @@ export const useSuggestions = ({
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) {
} else {
// Content confirmed - show SEO workflow suggestions
if (!seoAnalysis) {
// Prompt to run SEO analysis first
items.push({
@@ -189,22 +193,6 @@ export const useSuggestions = ({
});
}
}
} else {
// No content yet, but outline is confirmed - show content generation options
if (hasContent) {
// Content exists but not confirmed - show confirmation and SEO options
items.push({
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.'
});
items.push({
title: '📊 Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
} else {
// No content at all - show generation option (only if no content exists)
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
}
}
}

View File

@@ -1,9 +1,9 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, TextField, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse } from '../../../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
// Helper to create a consistent theme
@@ -48,10 +48,14 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
sectionImages = {}
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('Click "Generate Introduction" to create a compelling opening for your blog post based on your content and research.');
const [sections, setSections] = useState<any[]>([]);
const [isTitleLoading, setIsTitleLoading] = useState(false);
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
const [showIntroductionModal, setShowIntroductionModal] = useState(false);
const [generatedIntroductions, setGeneratedIntroductions] = useState<string[]>([]);
// Initialize sections from outline or use parent sections
useEffect(() => {
@@ -74,6 +78,61 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
}, [outline, parentSections]);
// Update sections when parentSections content changes (e.g., after SEO recommendations are applied)
// This effect specifically watches for content changes in parentSections and updates the corresponding sections
// Use a ref to track the previous parentSections content to detect actual content changes
const prevParentSectionsRef = useRef<string>('');
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
// Create a stringified version of parentSections for comparison
const parentSectionsString = JSON.stringify(parentSections);
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
// Update if content changed OR continuityRefresh changed (forced refresh)
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
return; // No changes detected
}
prevParentSectionsRef.current = parentSectionsString;
prevContinuityRefreshRef.current = continuityRefresh;
setSections(prevSections => {
// Update sections with new content from parentSections
const updatedSections = prevSections.map(section => {
// Try multiple ID formats to match sections (string, number, or stringified number)
const sectionIdStr = String(section.id);
const parentContent = parentSections[section.id] ||
parentSections[sectionIdStr] ||
parentSections[Number(section.id)];
// Update if parent has content for this section ID and it's different
if (parentContent !== undefined && parentContent !== section.content) {
console.log(`[BlogEditor] Updating section ${section.id} with new content (length: ${parentContent.length})`);
return {
...section,
content: parentContent
};
}
return section;
});
// Check if any sections were actually updated
const hasUpdates = updatedSections.some((section, index) =>
section.content !== prevSections[index]?.content
);
// Notify parent component of content update if changes were made
if (onContentUpdate && hasUpdates) {
onContentUpdate(updatedSections);
}
return updatedSections;
});
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
@@ -91,6 +150,51 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
setShowTitleModal(false);
}, []);
const handleGenerateIntroductions = useCallback(async () => {
if (!research || !outline.length || isIntroductionLoading) {
return;
}
setIsIntroductionLoading(true);
try {
const keywordAnalysis = research.keyword_analysis || {};
const primaryKeywords = keywordAnalysis.primary || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Build sections_content from current sections
const sectionsContent: Record<string, string> = {};
sections.forEach(section => {
if (section.content) {
sectionsContent[section.id] = section.content;
}
});
const result = await blogWriterApi.generateIntroductions({
blog_title: blogTitle,
research,
outline,
sections_content: sectionsContent,
primary_keywords: primaryKeywords,
search_intent: searchIntent
});
if (result.success && result.introductions) {
setGeneratedIntroductions(result.introductions);
setShowIntroductionModal(true);
}
} catch (error) {
console.error('Failed to generate introductions:', error);
alert('Failed to generate introductions. Please try again.');
} finally {
setIsIntroductionLoading(false);
}
}, [research, outline, sections, blogTitle, isIntroductionLoading]);
const handleIntroductionSelect = useCallback((selectedIntroduction: string) => {
setIntroduction(selectedIntroduction);
setShowIntroductionModal(false);
}, []);
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
@@ -139,9 +243,37 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Tooltip>
</div>
</div>
<p className="mt-3 text-gray-500 text-sm">
This is where your blog's subtitle or a brief one-line description will appear. It's editable too!
</p>
<div className="mt-3 group/intro">
<div className="flex items-start gap-2">
<p
className="flex-1 text-gray-600 text-sm leading-relaxed cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
onClick={() => {
const newIntro = prompt('Edit introduction:', introduction);
if (newIntro !== null && newIntro.trim()) {
setIntroduction(newIntro.trim());
}
}}
title="Click to edit introduction"
>
{introduction}
</p>
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-300">
<Tooltip title="✨ Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
size="small"
>
{isIntroductionLoading ? (
<CircularProgress size={20} />
) : (
<AutoAwesomeIcon className="text-blue-500" fontSize="small"/>
)}
</IconButton>
</Tooltip>
</div>
</div>
</div>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
@@ -301,6 +433,71 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Button>
</DialogActions>
</Dialog>
{/* Introduction Selection Modal */}
<Dialog
open={showIntroductionModal}
onClose={() => setShowIntroductionModal(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Introduction
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Select one of the AI-generated introductions below. Each offers a different approach to hooking your readers.
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{generatedIntroductions.map((intro, index) => (
<Box
key={index}
sx={{
mb: 3,
p: 2,
border: '1px solid',
borderColor: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main',
borderRadius: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => handleIntroductionSelect(intro)}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 1,
color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main'
}}
>
{index === 0 ? '📌 Option 1: Problem-Focused' : index === 1 ? '✨ Option 2: Benefit-Focused' : '📊 Option 3: Story/Statistic-Focused'}
</Typography>
<Typography
variant="body1"
sx={{
color: 'text.primary',
lineHeight: 1.7,
whiteSpace: 'pre-wrap'
}}
>
{intro}
</Typography>
</Box>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIntroductionModal(false)}>
Cancel
</Button>
</DialogActions>
</Dialog>
</div>
</ThemeProvider>
);