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:
@@ -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,
|
||||
|
||||
@@ -122,6 +122,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
research={research}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 high‑quality 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;
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user