chore: push all remaining changes

- Blog writer enhancements and bug fixes
- Wix integration improvements
- Frontend UI updates
- GSC dashboard docs cleanup
- Image studio assets
- LinkedIn requirements file
- Various dependency updates
This commit is contained in:
ajaysi
2026-06-12 20:32:03 +05:30
parent 63a0df2536
commit d90d441019
78 changed files with 3963 additions and 2899 deletions

View File

@@ -34,7 +34,8 @@
"react-scripts": "5.0.1",
"recharts": "^3.2.0",
"zod": "^3.25.76",
"zustand": "^5.0.7"
"zustand": "^5.0.7",
"marked": "^18.0.4"
},
"scripts": {
"start": "node --max_old_space_size=12288 node_modules/react-scripts/scripts/start.js",

View File

@@ -1,6 +1,21 @@
import axios from 'axios';
import { getApiBaseUrl } from '../utils/apiUrl';
// Harden axios against prototype pollution gadgets for config properties
// not present in default config. Setting explicit own properties on the
// defaults object forces mergeConfig to copy them into every request config,
// so they shadow any polluted value on Object.prototype.
//
// See https://github.com/AJaySi/ALwrity/security/dependabot/120
Object.assign(axios.defaults, {
proxy: false,
socketPath: '',
transport: null,
beforeRedirect: null,
httpAgent: null,
httpsAgent: null,
});
const sanitizeUrlForLogging = (url: string | undefined): string => {
if (!url) return '';
try {

View File

@@ -38,6 +38,8 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
import { useBlogAsset } from '../../hooks/useBlogAsset';
import { blogAssetAPI } from '../../api/blogAsset';
import { useContentPlanningStore } from '../../stores/contentPlanningStore';
import { useWorkflowStore } from '../../stores/workflowStore';
const BlogWriter: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -108,6 +110,12 @@ const BlogWriter: React.FC = () => {
restoreFromAsset
} = useBlogWriterState();
// Selected competitive advantage for outline generation — defaults to first
const [selectedCompetitiveAdvantage, setSelectedCompetitiveAdvantage] = useState<string>('');
const handleCompetitiveAdvantageSelect = useCallback((advantage: string) => {
setSelectedCompetitiveAdvantage(advantage);
}, []);
// SEO Manager - handles all SEO-related logic
// Initialize phase navigation with temporary false value for seoRecommendationsApplied
const [tempSeoRecommendationsApplied] = React.useState(false);
@@ -141,6 +149,7 @@ const BlogWriter: React.FC = () => {
isDiffModalOpen,
diffPreviewData,
acceptDiffChanges,
acceptSelectedDiffChanges,
rejectDiffChanges,
} = useSEOManager({
sections,
@@ -148,6 +157,7 @@ const BlogWriter: React.FC = () => {
research,
outline,
selectedTitle,
selectedCompetitiveAdvantage,
contentConfirmed,
seoAnalysis,
currentPhase: tempCurrentPhase,
@@ -169,6 +179,7 @@ const BlogWriter: React.FC = () => {
currentPhase,
navigateToPhase,
setCurrentPhase,
resetUserSelection: resetUserSelection2,
} = usePhaseNavigation(
research,
outline,
@@ -193,6 +204,7 @@ const BlogWriter: React.FC = () => {
currentPhase,
navigateToPhase,
setCurrentPhase,
resetUserSelection: resetUserSelection2,
});
// All SEO management logic is now in useSEOManager hook above
@@ -234,12 +246,6 @@ const BlogWriter: React.FC = () => {
}
}, [research]);
// Selected competitive advantage for outline generation — defaults to first
const [selectedCompetitiveAdvantage, setSelectedCompetitiveAdvantage] = useState<string>('');
const handleCompetitiveAdvantageSelect = useCallback((advantage: string) => {
setSelectedCompetitiveAdvantage(advantage);
}, []);
// Auto-select first competitive advantage when research loads
React.useEffect(() => {
const advantages = research?.competitor_analysis?.competitive_advantages;
@@ -280,7 +286,7 @@ const BlogWriter: React.FC = () => {
} = useBlogAsset();
// Load blog asset passed via React Router state (from Asset Library)
const location = useLocation();
const locationState = location.state as { restoreBlogAssetId?: number } | null;
const locationState = location.state as { restoreBlogAssetId?: number; calendarTopic?: string; calendarDescription?: string; calendarEventId?: string; workflowTaskId?: string } | null;
// Persist last active asset_id across refreshes
const saveLastAssetId = useCallback((id: number) => {
@@ -623,9 +629,29 @@ const BlogWriter: React.FC = () => {
navigateToPhase,
});
const handleOpenSEOMetadata = React.useCallback(() => {
setIsSEOMetadataModalOpen(true);
}, [setIsSEOMetadataModalOpen]);
const handleRunFlowAnalysis = React.useCallback(async () => {
try {
const payload = {
title: selectedTitle || 'Blog Post',
sections: outline.map(s => ({
id: s.id,
heading: s.heading,
content: sections[s.id] || '',
})),
};
const result = await blogWriterApi.analyzeFlowBasic(payload);
if (result.success && result.analysis) {
setFlowAnalysisResults(result.analysis);
setFlowAnalysisCompleted(true);
}
} catch (err) {
console.error('Flow analysis failed:', err);
}
}, [selectedTitle, outline, sections, setFlowAnalysisResults, setFlowAnalysisCompleted]);
return (
<div style={{
@@ -717,6 +743,23 @@ const BlogWriter: React.FC = () => {
});
saveLastAssetId(assetId);
}
// Mark originating calendar event as published
const eventId = locationState?.calendarEventId;
if (eventId) {
const { updateEvent } = useContentPlanningStore.getState();
updateEvent(eventId, { status: 'published' }).catch((err: any) =>
console.warn('[BlogWriter] Failed to update calendar event:', err)
);
}
// Mark the workflow task as completed and navigate back
const taskId = locationState?.workflowTaskId;
if (taskId) {
const { completeTask } = useWorkflowStore.getState();
completeTask(taskId).catch((err: any) =>
console.warn('[BlogWriter] Failed to complete workflow task:', err)
);
setTimeout(() => navigate('/dashboard'), 1500);
}
}}
/>
@@ -817,6 +860,8 @@ const BlogWriter: React.FC = () => {
onAngleSelect={handleAngleSelect}
selectedCompetitiveAdvantage={selectedCompetitiveAdvantage}
onCompetitiveAdvantageSelect={handleCompetitiveAdvantageSelect}
onOpenSEOMetadata={handleOpenSEOMetadata}
onRunFlowAnalysis={handleRunFlowAnalysis}
/>
</>
)}
@@ -843,6 +888,8 @@ const BlogWriter: React.FC = () => {
blogContent={buildFullMarkdown()}
blogTitle={selectedTitle}
researchData={research}
outline={outline}
competitiveAdvantage={selectedCompetitiveAdvantage}
onApplyRecommendations={handleApplySeoRecommendations}
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
/>
@@ -852,6 +899,7 @@ const BlogWriter: React.FC = () => {
isOpen={isDiffModalOpen}
diffData={diffPreviewData}
onAccept={acceptDiffChanges}
onAcceptSelected={acceptSelectedDiffChanges}
onReject={rejectDiffChanges}
/>
@@ -864,12 +912,10 @@ const BlogWriter: React.FC = () => {
researchData={research}
outline={outline}
seoAnalysis={seoAnalysis}
sectionImages={sectionImages}
onMetadataGenerated={(metadata) => {
console.log('SEO metadata generated:', metadata);
setSeoMetadata(metadata);
// Metadata is now saved and will be used when publishing to WordPress/Wix
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
// Publisher component will use this metadata when calling publish API
}}
/>

View File

@@ -14,6 +14,7 @@ interface BlogWriterLandingSectionProps {
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
restoreAttempted?: boolean;
onBrainstormResult?: (result: import('../../../api/gscBrainstorm').BrainstormResult) => void;
initialKeywords?: string;
}
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
@@ -29,6 +30,7 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
startResearchRef,
restoreAttempted = false,
onBrainstormResult,
initialKeywords,
}) => {
if (!research) {
if (currentPhase === 'research') {
@@ -39,6 +41,7 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
blogLengthRef={blogLengthRef}
researchRef={startResearchRef}
onBrainstormResult={onBrainstormResult}
initialKeywords={initialKeywords}
/>
);
}

View File

@@ -43,6 +43,8 @@ interface PhaseContentProps {
onContentGenerationStart?: (taskId: string) => void;
buildFullMarkdown?: () => string;
convertMarkdownToHTML?: (md: string) => string;
onOpenSEOMetadata?: () => void;
onRunFlowAnalysis?: () => void;
brainstormResult?: import('../../../api/gscBrainstorm').BrainstormResult;
onBrainstormResult?: (result: import('../../../api/gscBrainstorm').BrainstormResult) => void;
onResearchWithKeywords?: (keywords: string) => void;
@@ -99,6 +101,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onCompetitiveAdvantageSelect,
introduction,
onIntroductionUpdate,
onOpenSEOMetadata,
onRunFlowAnalysis,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -249,6 +253,10 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
seoMetadata={seoMetadata}
seoAnalysis={seoAnalysis}
blogTitle={selectedTitle ?? undefined}
sectionImages={sectionImages}
onOpenSEOMetadata={onOpenSEOMetadata}
flowAnalysisResults={flowAnalysisResults}
onRunFlowAnalysis={onRunFlowAnalysis}
/>
)}
</div>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { apiClient } from '../../../api/client';
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../api/wordpress';
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
import { blogWriterApi, BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
import hallucinationDetectorService from '../../../services/hallucinationDetectorService';
import WixConnectModal from './WixConnectModal';
import { useWixPublish } from '../../../hooks/useWixPublish';
import { useTextToSpeech } from '../../../hooks/useTextToSpeech';
@@ -9,12 +10,18 @@ import { useTextToSpeech } from '../../../hooks/useTextToSpeech';
const saveCompleteBlogAsset = async (
title: string,
content: string,
seoMetadata: BlogSEOMetadataResponse | null
seoMetadata: BlogSEOMetadataResponse | null,
platform?: string,
post_url?: string,
post_id?: string,
) => {
try {
await apiClient.post('/api/blog/save-complete-asset', {
title,
content,
platform: platform || null,
post_url: post_url || null,
post_id: post_id || null,
seo_title: seoMetadata?.seo_title,
meta_description: seoMetadata?.meta_description,
focus_keyword: seoMetadata?.focus_keyword,
@@ -32,13 +39,22 @@ interface PublishContentProps {
seoMetadata: BlogSEOMetadataResponse | null;
seoAnalysis?: any;
blogTitle?: string;
sectionImages?: Record<string, string>;
onOpenSEOMetadata?: () => void;
flowAnalysisResults?: any;
onRunFlowAnalysis?: () => void;
}
export const PublishContent: React.FC<PublishContentProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata,
seoAnalysis,
blogTitle,
sectionImages,
onOpenSEOMetadata,
flowAnalysisResults,
onRunFlowAnalysis,
}) => {
const {
wixStatus,
@@ -58,6 +74,16 @@ export const PublishContent: React.FC<PublishContentProps> = ({
const [publishResult, setPublishResult] = useState<{ platform: string; success: boolean; message: string; url?: string } | null>(null);
const [copyDone, setCopyDone] = useState(false);
const [wixContentWarning, setWixContentWarning] = useState<string | null>(null);
const [flowRunning, setFlowRunning] = useState(false);
const [hallucinationResults, setHallucinationResults] = useState<any>(null);
const [hallucinationRunning, setHallucinationRunning] = useState(false);
const [publishHistory, setPublishHistory] = useState<{ entries: any[]; total: number } | null>(null);
const [showPublishHistory, setShowPublishHistory] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (flowRunning && flowAnalysisResults) setFlowRunning(false);
}, [flowAnalysisResults, flowRunning]);
// Audio / TTS
const { speak, stop, isSpeaking, isSupported } = useTextToSpeech();
@@ -140,6 +166,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
const result = await wordpressAPI.publishContent(request);
if (result.success) {
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata, 'wordpress', result.post_url, String(result.post_id ?? ''));
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
} else {
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
@@ -151,11 +178,13 @@ export const PublishContent: React.FC<PublishContentProps> = ({
}
};
// Inject section images from localStorage into markdown so Wix can publish them
// Inject section images from state (or localStorage fallback) into markdown
const enrichMarkdownWithImages = (markdown: string): string => {
try {
const images = sectionImages && Object.keys(sectionImages).length > 0
? sectionImages
: JSON.parse(localStorage.getItem('blog_section_images') || '{}');
const outline = JSON.parse(localStorage.getItem('blog_outline') || '[]');
const images = JSON.parse(localStorage.getItem('blog_section_images') || '{}');
if (!outline.length || !Object.keys(images).length) return markdown;
let enriched = markdown;
@@ -195,7 +224,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
setWixContentWarning(result.warning);
}
if (result.success) {
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata, 'wix', result.url, result.post_id);
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
}
};
@@ -238,6 +267,37 @@ export const PublishContent: React.FC<PublishContentProps> = ({
transition: 'all 0.2s',
};
const handleOpenPublishHistory = async () => {
setShowPublishHistory(true);
if (!publishHistory) {
setHistoryLoading(true);
try {
const { data } = await apiClient.get('/api/blog/publish-history?limit=50');
if (data.success) {
setPublishHistory({ entries: data.entries, total: data.total });
}
} catch (err) {
console.error('Failed to load publish history:', err);
} finally {
setHistoryLoading(false);
}
}
};
const handleRunHallucinationCheck = async () => {
setHallucinationRunning(true);
try {
const text = buildFullMarkdown();
const result = await hallucinationDetectorService.detectHallucinations({ text });
setHallucinationResults(result);
} catch (err) {
console.error('Hallucination check failed:', err);
setHallucinationResults({ success: false, error: 'Check failed' });
} finally {
setHallucinationRunning(false);
}
};
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#0f172a' }}>Publish Your Blog</h2>
@@ -246,6 +306,134 @@ export const PublishContent: React.FC<PublishContentProps> = ({
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* SEO Metadata card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>SEO Metadata</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
{seoMetadata ? 'Generated' : 'Not generated'}
</p>
</div>
<button
onClick={onOpenSEOMetadata}
style={{
...btnStyle,
background: seoMetadata ? '#f1f5f9' : 'linear-gradient(135deg, #059669, #047857)',
color: seoMetadata ? '#334155' : '#fff',
border: seoMetadata ? '1px solid #e2e8f0' : 'none',
cursor: 'pointer',
}}
>
{seoMetadata ? 'View SEO Metadata' : 'Generate SEO Metadata'}
</button>
</div>
{seoMetadata && (
<div style={{ marginTop: 8, fontSize: '0.85rem', color: '#334155' }}>
<div style={{ fontWeight: 600 }}>{seoMetadata.seo_title}</div>
<div style={{ color: '#64748b', marginTop: 2, lineHeight: 1.4 }}>{seoMetadata.meta_description}</div>
{seoMetadata.focus_keyword && (
<div style={{ marginTop: 4 }}>
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '2px 8px', borderRadius: 4, fontSize: '0.75rem', fontWeight: 500 }}>
{seoMetadata.focus_keyword}
</span>
</div>
)}
</div>
)}
</div>
{/* Pre-Publish Readiness Check */}
<div style={cardStyle}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Pre-Publish Readiness Check</h3>
<p style={{ margin: '4px 0 12px 0', fontSize: '0.85rem', color: '#64748b' }}>
Verify your content is ready before publishing
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* SEO Metadata check */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: seoMetadata ? '#f0fdf4' : '#fef2f2', borderRadius: 8, border: `1px solid ${seoMetadata ? '#86efac' : '#fecaca'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '1rem' }}>{seoMetadata ? '✅' : '❌'}</span>
<div>
<div style={{ fontWeight: 500, fontSize: '0.85rem', color: '#0f172a' }}>SEO Metadata</div>
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>
{seoMetadata ? `Generated (Score: ${seoAnalysis?.overall_score ?? 'N/A'}/100)` : 'Not generated'}
</div>
</div>
</div>
{seoMetadata && (
<button onClick={onOpenSEOMetadata} style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0', padding: '4px 12px', fontSize: '0.8rem', cursor: 'pointer' }}>
View
</button>
)}
</div>
{/* Flow Analysis check */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: flowAnalysisResults ? '#f0fdf4' : '#fafafa', borderRadius: 8, border: `1px solid ${flowAnalysisResults ? '#86efac' : '#e2e8f0'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '1rem' }}>{flowAnalysisResults ? '✅' : '🔲'}</span>
<div>
<div style={{ fontWeight: 500, fontSize: '0.85rem', color: '#0f172a' }}>Flow Analysis</div>
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>
{flowAnalysisResults
? `Flow: ${(flowAnalysisResults.overall_flow_score * 100).toFixed(0)} | Consistency: ${(flowAnalysisResults.overall_consistency_score * 100).toFixed(0)} | Progression: ${(flowAnalysisResults.overall_progression_score * 100).toFixed(0)}`
: 'Not yet run'}
</div>
</div>
</div>
<button
onClick={() => {
if (onRunFlowAnalysis) {
setFlowRunning(true);
onRunFlowAnalysis();
// Reset loading after a reasonable timeout
setTimeout(() => setFlowRunning(false), 30000);
}
}}
disabled={flowRunning || !onRunFlowAnalysis}
style={{ ...btnStyle, background: flowRunning ? '#e2e8f0' : 'linear-gradient(135deg, #6366f1, #4f46e5)', color: flowRunning ? '#94a3b8' : '#fff', padding: '4px 12px', fontSize: '0.8rem', cursor: flowRunning ? 'not-allowed' : 'pointer' }}
>
{flowRunning ? 'Analyzing...' : flowAnalysisResults ? 'Re-analyze' : 'Run Analysis'}
</button>
</div>
{/* Hallucination Check */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: hallucinationResults?.success ? '#f0fdf4' : hallucinationResults && !hallucinationResults.success ? '#fef2f2' : '#fafafa', borderRadius: 8, border: `1px solid ${hallucinationResults?.success ? '#86efac' : hallucinationResults && !hallucinationResults.success ? '#fecaca' : '#e2e8f0'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '1rem' }}>{hallucinationResults?.success ? '✅' : hallucinationResults && !hallucinationResults.success ? '❌' : '🔲'}</span>
<div>
<div style={{ fontWeight: 500, fontSize: '0.85rem', color: '#0f172a' }}>Hallucination Check</div>
<div style={{ fontSize: '0.8rem', color: '#64748b' }}>
{hallucinationResults?.success
? `${hallucinationResults.supported_claims ?? 0} supported, ${hallucinationResults.refuted_claims ?? 0} refuted, ${hallucinationResults.insufficient_claims ?? 0} unclear (${(hallucinationResults.overall_confidence * 100).toFixed(0)}% confidence)`
: hallucinationResults?.error
? hallucinationResults.error
: 'Not yet run'}
</div>
</div>
</div>
<button
onClick={handleRunHallucinationCheck}
disabled={hallucinationRunning}
style={{ ...btnStyle, background: hallucinationRunning ? '#e2e8f0' : 'linear-gradient(135deg, #dc2626, #b91c1c)', color: hallucinationRunning ? '#94a3b8' : '#fff', padding: '4px 12px', fontSize: '0.8rem', cursor: hallucinationRunning ? 'not-allowed' : 'pointer' }}
>
{hallucinationRunning ? 'Checking...' : hallucinationResults ? 'Re-check' : 'Run Check'}
</button>
</div>
</div>
{/* Overall status */}
<div style={{ marginTop: 8, fontSize: '0.8rem', fontWeight: 500, color: (seoMetadata && flowAnalysisResults && hallucinationResults?.success) ? '#166534' : '#92400e' }}>
{(seoMetadata && flowAnalysisResults && hallucinationResults?.success)
? '✅ All checks passed — ready to publish!'
: seoMetadata && flowAnalysisResults
? '⚠️ Run hallucination check before publishing for best results'
: seoMetadata
? '⚠️ Run flow analysis and hallucination check before publishing'
: '⚠️ Generate SEO metadata and run quality checks before publishing'}
</div>
</div>
{/* WordPress card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -281,7 +469,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Wix</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
{checkingWix ? 'Checking connection...' : wixStatus?.connected ? 'Connected' : 'Not connected'}
{checkingWix ? 'Checking connection...' : wixStatus?.connected ? 'Connected' : wixStatus?.error || 'Not connected'}
</p>
</div>
<button
@@ -364,6 +552,46 @@ export const PublishContent: React.FC<PublishContentProps> = ({
View published post
</a>
)}
{/* Post-publish actions (disabled placeholders for future features) */}
{publishResult.success && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #bbf7d0' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 600, color: '#166534', marginBottom: 8 }}>
More Actions
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
disabled
title="Coming soon — update the published post with latest edits"
style={{
...btnStyle, background: '#e2e8f0', color: '#94a3b8',
cursor: 'not-allowed', fontSize: '0.8rem',
}}
>
Update Published Post
</button>
<button
disabled
title="Coming soon — schedule publish for a future date/time"
style={{
...btnStyle, background: '#e2e8f0', color: '#94a3b8',
cursor: 'not-allowed', fontSize: '0.8rem',
}}
>
Schedule Publish
</button>
<button
onClick={handleOpenPublishHistory}
style={{
...btnStyle, background: '#f1f5f9', color: '#334155',
border: '1px solid #e2e8f0', cursor: 'pointer', fontSize: '0.8rem',
}}
>
Publish History
</button>
</div>
</div>
)}
</div>
)}
@@ -372,6 +600,55 @@ export const PublishContent: React.FC<PublishContentProps> = ({
onClose={closeWixConnectModal}
onConnectionSuccess={handleWixConnectionSuccess}
/>
{/* Publish History modal */}
{showPublishHistory && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 1000,
}} onClick={() => setShowPublishHistory(false)}>
<div style={{
background: '#fff', borderRadius: 12, padding: 24,
maxWidth: 600, width: '90%', maxHeight: '80vh', overflow: 'auto',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, color: '#0f172a' }}>Publish History</h3>
<button onClick={() => setShowPublishHistory(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2rem', color: '#64748b' }}></button>
</div>
{historyLoading ? (
<div style={{ textAlign: 'center', padding: 24, color: '#64748b' }}>Loading history...</div>
) : publishHistory && publishHistory.entries.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{publishHistory.entries.map((entry: any) => (
<div key={entry.asset_id} style={{
padding: 12, borderRadius: 8, border: '1px solid #e2e8f0',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div>
<div style={{ fontWeight: 500, fontSize: '0.85rem', color: '#0f172a' }}>{entry.title}</div>
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: 2 }}>
{entry.platform === 'wix' ? 'Wix' : entry.platform === 'wordpress' ? 'WordPress' : entry.platform}
{entry.published_at && ` · ${new Date(entry.published_at).toLocaleDateString()}`}
{entry.word_count > 0 && ` · ${entry.word_count} words`}
</div>
</div>
{entry.post_url && (
<a href={entry.post_url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8rem', color: '#6366f1', textDecoration: 'none' }}>
View
</a>
)}
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#94a3b8' }}>
No publish history yet. Publish your blog to see it here.
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -52,18 +52,20 @@ export const useBlogWriterPolling = ({
});
onSectionsUpdate(newSections);
// Skip auto-confirm and navigation when Re-Content was used
// Skip auto-navigation when Re-Content was used
// (user already had content and chose to regenerate — stay on content phase to review)
const skipAutoConfirm = skipContentAutoConfirmRef?.current === true;
if (skipContentAutoConfirmRef) skipContentAutoConfirmRef.current = false; // reset flag
// Always confirm content so the check mark shows on the chip
if (onContentConfirmed) {
onContentConfirmed();
}
if (skipAutoConfirm) {
debug.log('[BlogWriter] Re-Content: skipping auto-confirm and navigation (user stays on content phase)');
debug.log('[BlogWriter] Re-Content: content confirmed, user stays on content phase to review');
} else {
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens for initial content generation (first time)
if (onContentConfirmed) {
onContentConfirmed();
}
// Auto-navigate to SEO phase when content generation completes (first time)
if (navigateToPhase) {
navigateToPhase('seo');
}

View File

@@ -201,16 +201,18 @@ export const usePhaseActionHandlers = ({
}, [outline, research, selectedTitle, sections, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, mediumPolling, onContentComplete]);
const handleSEOAction = useCallback(() => {
debug.log('[BlogWriter] handleSEOAction called', { contentConfirmed, hasSeoAnalysis: !!seoAnalysis });
if (!contentConfirmed) {
setContentConfirmed(true);
}
navigateToPhase('seo');
const navResult = navigateToPhase('seo');
debug.log('[BlogWriter] navigateToPhase(seo) returned', { navResult });
if (seoAnalysis) {
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] SEO analysis exists - opening modal for review');
} else {
runSEOAnalysisDirect();
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
const result = runSEOAnalysisDirect();
debug.log('[BlogWriter] runSEOAnalysisDirect returned', { result });
}
}, [contentConfirmed, seoAnalysis, setContentConfirmed, navigateToPhase, setIsSEOAnalysisModalOpen, runSEOAnalysisDirect]);

View File

@@ -8,6 +8,7 @@ interface UsePhaseRestorationProps {
currentPhase: string;
navigateToPhase: (phase: string) => void;
setCurrentPhase: (phase: string) => void;
resetUserSelection?: () => void;
}
export const usePhaseRestoration = ({
@@ -17,6 +18,7 @@ export const usePhaseRestoration = ({
currentPhase,
navigateToPhase,
setCurrentPhase,
resetUserSelection,
}: UsePhaseRestorationProps) => {
const hasRestoredRef = useRef(false);
@@ -55,7 +57,10 @@ export const usePhaseRestoration = ({
if (targetPhase && !targetPhase.disabled) {
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);
setCurrentPhase(restoredPhase);
// Phase restoration complete - the usePhaseNavigation hook will handle persistence
// Reset user selection so auto-progression can correct stale phases
if (resetUserSelection) {
resetUserSelection();
}
} else {
console.log('[BlogWriter] Restored phase is disabled or not found, keeping current phase:', {
restoredPhase,

View File

@@ -184,6 +184,7 @@ interface UseSEOManagerProps {
research: any;
outline: any[];
selectedTitle: string | null;
selectedCompetitiveAdvantage?: string;
contentConfirmed: boolean;
seoAnalysis: any;
currentPhase: string;
@@ -205,6 +206,7 @@ export const useSEOManager = ({
research,
outline,
selectedTitle,
selectedCompetitiveAdvantage,
contentConfirmed,
seoAnalysis,
currentPhase,
@@ -235,8 +237,11 @@ export const useSEOManager = ({
const originalSectionsRef = useRef<Record<string, string> | null>(null);
const originalIntroductionRef = useRef<string | null>(null);
// Restore cached SEO analysis on mount when sections are available
// Restore cached SEO analysis only when user is on/past the SEO phase
useEffect(() => {
// Don't run SEO cache lookups on research or outline phases
if (currentPhase !== 'seo' && currentPhase !== 'publish') return;
const restoreCachedSEO = async () => {
if (seoAnalysis) return;
@@ -249,18 +254,13 @@ export const useSEOManager = ({
try {
const hash = await hashContent(`${title}\n${fullMarkdown}`);
const cacheKey = getSeoCacheKey(hash, title);
console.log('[SEOManager] SEO cache lookup', { cacheKey, hashLength: hash.length, titleLength: title.length, markdownLength: fullMarkdown.length });
const cached = window.localStorage.getItem(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
debug.log('[SEOManager] Restored cached SEO analysis', { score: parsed.overall_score });
setSeoAnalysis(parsed);
} else {
console.log('[SEOManager] Cached SEO data invalid', { hasScore: parsed && typeof parsed.overall_score === 'number' });
}
} else {
console.log('[SEOManager] SEO cache miss', { cacheKey });
}
} catch (e) {
debug.log('[SEOManager] Failed to restore cached SEO analysis', e);
@@ -273,10 +273,9 @@ export const useSEOManager = ({
const wasApplied = localStorage.getItem('blog_seo_recommendations_applied') === 'true';
if (wasApplied) {
setSeoRecommendationsApplied(true);
debug.log('[SEOManager] Restored seoRecommendationsApplied flag');
}
} catch {}
}, [selectedTitle, sections, outline, seoAnalysis, setSeoAnalysis, setSeoRecommendationsApplied]);
}, [currentPhase, selectedTitle, sections, outline, seoAnalysis, setSeoAnalysis, setSeoRecommendationsApplied]);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
@@ -306,6 +305,7 @@ export const useSEOManager = ({
const hasResearch = !!research && !!(research as any).keyword_analysis;
console.debug('[SEODirect] runSEOAnalysisDirect', { hasSections, hasValidContent, hasResearch, sectionKeys: Object.keys(sections), outlineLen: outline?.length, isModalOpen: isSEOAnalysisModalOpen, contentConfirmed });
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.";
}
@@ -373,6 +373,7 @@ export const useSEOManager = ({
outline,
research: (research as any) || {},
recommendations,
competitive_advantage: selectedCompetitiveAdvantage || undefined,
});
if (!response.success) {
@@ -460,7 +461,7 @@ export const useSEOManager = ({
} catch (cacheError) {
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
}
}, [outline, research, sections, introduction, selectedTitle, setSections]);
}, [outline, research, sections, introduction, selectedTitle, selectedCompetitiveAdvantage, setSections]);
const acceptDiffChanges = useCallback(() => {
const normalizedSections = pendingSectionsRef.current;
@@ -538,6 +539,87 @@ export const useSEOManager = ({
setDiffPreviewData(null);
}, []);
const acceptSelectedDiffChanges = useCallback((
selectedIds: Record<string, boolean>,
acceptIntro: boolean
) => {
const pendingSections = pendingSectionsRef.current;
const originalSections = originalSectionsRef.current;
const uniqueSectionKeys = pendingSectionsKeysRef.current;
if (!pendingSections || !originalSections || !uniqueSectionKeys) {
debug.log('[BlogWriter] acceptSelectedDiffChanges: no pending changes to apply');
return;
}
// Merge: selected sections use pending content, unselected use original
const mergedSections: Record<string, string> = {};
const allKeys = new Set([...Object.keys(pendingSections), ...Object.keys(originalSections)]);
allKeys.forEach(key => {
if (selectedIds[key]) {
mergedSections[key] = pendingSections[key] || originalSections[key] || '';
} else {
mergedSections[key] = originalSections[key] || pendingSections[key] || '';
}
});
const mergedKeys = Object.keys(mergedSections);
debug.log('[BlogWriter] Accepting selected diff changes', {
selected: Object.entries(selectedIds).filter(([, v]) => v).length,
totalSections: mergedKeys.length,
});
setSections(mergedSections);
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
// Introduction: only apply if acceptIntro is true
const pendingIntro = pendingIntroductionRef.current;
if (acceptIntro && pendingIntro !== null && pendingIntro !== introduction) {
setIntroduction(pendingIntro);
debug.log('[BlogWriter] Introduction updated from selected SEO response', {
length: pendingIntro.length,
});
}
// Title: always apply if changed (not per-section granularity)
const pendingTitle = pendingTitleRef.current;
if (pendingTitle && pendingTitle !== selectedTitle) {
setSelectedTitle(pendingTitle);
}
if (pendingAppliedRef.current) {
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: pendingAppliedRef.current } : prev);
}
setSeoRecommendationsApplied(true);
try {
localStorage.setItem('blog_seo_recommendations_applied', 'true');
} catch {}
if (currentPhase !== 'seo') {
navigateToPhase('seo');
}
// Clean up pending and close
pendingSectionsRef.current = null;
pendingSectionsKeysRef.current = null;
pendingIntroductionRef.current = null;
pendingTitleRef.current = null;
pendingAppliedRef.current = null;
originalSectionsRef.current = null;
originalIntroductionRef.current = null;
setIsDiffModalOpen(false);
setDiffPreviewData(null);
try {
blogWriterCache.cacheContent(mergedSections, mergedKeys);
} catch (cacheError) {
debug.log('[BlogWriter] Failed to cache selected SEO content', cacheError);
}
}, [setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setIntroduction, introduction, setSelectedTitle, selectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
setSeoAnalysis(analysis);
@@ -573,17 +655,11 @@ export const useSEOManager = ({
// Mark SEO phase as completed when recommendations are applied
useEffect(() => {
if (seoRecommendationsApplied && seoAnalysis) {
// Only auto-navigate to SEO if user is already on/past the SEO phase
if (seoRecommendationsApplied && seoAnalysis && (currentPhase === 'seo' || currentPhase === 'publish')) {
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
// Ensure we stay in SEO phase only once when recommendations are first applied
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Navigated to SEO phase to show updated content');
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seoRecommendationsApplied, seoAnalysis]);
}, [seoRecommendationsApplied, seoAnalysis, currentPhase]);
const confirmBlogContent = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');
@@ -614,6 +690,7 @@ export const useSEOManager = ({
diffPreviewData,
acceptDiffChanges,
rejectDiffChanges,
acceptSelectedDiffChanges,
};
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import React, { useState, useMemo } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, IconButton, Divider
Button, Typography, Box, Chip, IconButton, Checkbox, FormControlLabel, Divider
} from '@mui/material';
import { Close as CloseIcon, Check as CheckIcon } from '@mui/icons-material';
import type { DiffPreviewData, DiffSegment } from '../../../utils/getSectionDiffs';
@@ -11,6 +11,7 @@ interface DiffPreviewModalProps {
diffData: DiffPreviewData | null;
onAccept: () => void;
onReject: () => void;
onAcceptSelected?: (selectedSectionIds: Record<string, boolean>, acceptIntroduction: boolean) => void;
loading?: boolean;
}
@@ -60,11 +61,51 @@ export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
diffData,
onAccept,
onReject,
onAcceptSelected,
loading = false,
}) => {
// Per-section selection state — default: select all changed sections
const [sectionSelection, setSectionSelection] = useState<Record<string, boolean>>({});
const [acceptIntroduction, setAcceptIntroduction] = useState(true);
// Initialize defaults when diffData changes
React.useEffect(() => {
if (!diffData) return;
const initial: Record<string, boolean> = {};
diffData.sectionDiffs.forEach(s => {
initial[s.id] = s.changed;
});
setSectionSelection(initial);
setAcceptIntroduction(diffData.introductionChanged);
}, [diffData]);
if (!diffData) return null;
const hasAnyChange = diffData.introductionChanged || diffData.sectionDiffs.some(s => s.changed);
const selectedCount = Object.values(sectionSelection).filter(Boolean).length;
const hasAnySelected = selectedCount > 0 || acceptIntroduction;
const toggleSection = (id: string) => {
setSectionSelection(prev => ({ ...prev, [id]: !prev[id] }));
};
const handleAcceptSelected = () => {
if (onAcceptSelected) {
onAcceptSelected(sectionSelection, acceptIntroduction);
} else {
onAccept();
}
};
const allSelected = diffData.sectionDiffs.every(s => sectionSelection[s.id]);
const toggleAll = () => {
const newVal = !allSelected;
const updated: Record<string, boolean> = {};
diffData.sectionDiffs.forEach(s => {
updated[s.id] = newVal;
});
setSectionSelection(updated);
};
return (
<Dialog open={isOpen} maxWidth="lg" fullWidth fullScreen>
@@ -91,6 +132,12 @@ export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
size="small"
variant="outlined"
/>
<Chip
label={`${selectedCount} of ${diffData.sectionDiffs.length} selected`}
color={selectedCount > 0 ? 'primary' : 'default'}
size="small"
variant="outlined"
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', ml: 'auto' }}>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', fontSize: '0.75rem', color: '#166534' }}>
<Box sx={{ width: 14, height: 14, bgcolor: '#dcfce7', border: '1px solid #86efac', borderRadius: '2px' }} />
@@ -111,25 +158,53 @@ export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
</Typography>
)}
{/* Introduction — toggle */}
{diffData.introductionChanged && (
<Box sx={{ mb: 4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 1 }}>
Introduction
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Checkbox
checked={acceptIntroduction}
onChange={() => setAcceptIntroduction(!acceptIntroduction)}
size="small"
sx={{ p: 0, mr: 1 }}
/>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: acceptIntroduction ? '#475569' : '#94a3b8' }}>
Introduction
</Typography>
</Box>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
{renderDiffSegments(diffData.introductionDiff!)}
</Box>
</Box>
)}
{/* Select / Deselect All */}
{diffData.sectionDiffs.some(s => s.changed) && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" variant="text" onClick={toggleAll} sx={{ textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', p: 0 }}>
{allSelected ? 'Deselect All' : 'Select All'}
</Button>
</Box>
)}
{/* Per-section diffs with checkbox */}
{diffData.sectionDiffs.map((section, idx) => {
if (!section.changed) return null;
const isSelected = sectionSelection[section.id] ?? true;
return (
<Box key={section.heading || idx} sx={{ mb: 3 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 0.5 }}>
{section.heading}
</Typography>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
<Box key={section.id || idx} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<Checkbox
checked={isSelected}
onChange={() => toggleSection(section.id)}
size="small"
sx={{ p: 0, mr: 1 }}
/>
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: isSelected ? '#475569' : '#94a3b8' }}>
{section.heading}
</Typography>
</Box>
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b', opacity: isSelected ? 1 : 0.5 }}>
{renderDiffSegments(section.segments)}
</Box>
</Box>
@@ -137,7 +212,7 @@ export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
})}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white' }}>
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', justifyContent: 'space-between' }}>
<Button
onClick={onReject}
disabled={loading}
@@ -145,20 +220,31 @@ export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
color="error"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Reject Changes
</Button>
<Button
onClick={onAccept}
disabled={loading}
variant="contained"
color="primary"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Accept Changes
Reject All Changes
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={onAccept}
disabled={loading}
variant="outlined"
color="inherit"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Accept All
</Button>
<Button
onClick={handleAcceptSelected}
disabled={loading || !hasAnySelected}
variant="contained"
color="primary"
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Accept Selected ({selectedCount + (acceptIntroduction && diffData.introductionChanged ? 1 : 0)})
</Button>
</Box>
</DialogActions>
</Dialog>
);
};
export default DiffPreviewModal;
export default DiffPreviewModal;

View File

@@ -10,10 +10,11 @@ interface ManualResearchFormProps {
blogLengthRef?: React.MutableRefObject<string>;
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
initialKeywords?: string;
}
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef, onBrainstormResult }) => {
const [keywords, setKeywords] = useState('');
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef, onBrainstormResult, initialKeywords }) => {
const [keywords, setKeywords] = useState(initialKeywords ?? '');
const [blogLength, setBlogLength] = useState('1000');
// Sync keywords to parent for header chip label

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import { debug } from '../../utils/debug';
export interface Phase {
id: string;
@@ -120,7 +121,8 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
break;
case 'seo':
if (hasContent) {
return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
const handler = actionHandlers.onSEOAction || null;
return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler };
}
break;
case 'publish':
@@ -252,7 +254,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: '#fff',
boxShadow: '0 2px 6px rgba(16, 185, 129, 0.25)',
maxWidth: iconOnly ? '36px' : 'none',
maxWidth: iconOnly ? '44px' : 'none',
opacity: iconOnly ? 0.85 : 1,
'&:hover': {
maxWidth: iconOnly ? '160px' : 'none',
@@ -360,12 +362,12 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
sx={chipSx}
>
<Box component="span" sx={iconSx}>{phase.icon}</Box>
{isDone && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0 }}></Box>
)}
<Box component="span" sx={{ flexShrink: 0 }}>
{phase.id === 'research' && hasResearch ? 'Re-Research' : phase.id === 'research' && !hasResearch && researchKeywords ? 'Click To Research' : phase.id === 'research' && !hasResearch ? 'Start Now' : phase.id === 'outline' && hasOutline ? 'Re-Generate' : phase.id === 'outline' && !hasOutline ? 'Create Now' : phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
</Box>
{isDone && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}></Box>
)}
</Box>
</Tooltip>

View File

@@ -533,229 +533,155 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
<div
style={{
width: '100%',
maxWidth: 940,
maxHeight: '82vh',
maxWidth: 700,
maxHeight: '85vh',
background: '#ffffff',
borderRadius: 18,
boxShadow: '0 28px 80px rgba(15, 23, 42, 0.25)',
borderRadius: 16,
boxShadow: '0 20px 60px rgba(15, 23, 42, 0.2)',
border: '1px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
{/* Compact header */}
<div
style={{
padding: '28px 32px 24px 32px',
padding: '16px 20px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
position: 'relative'
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<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 id="research-progress-title" style={{ margin: 0, fontSize: 22, color: '#0f172a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
{isRunning && <CircularProgress size={18} thickness={4} sx={{ color: '#2563eb', flexShrink: 0 }} />}
<div style={{ minWidth: 0 }}>
<h3 id="research-progress-title" style={{ margin: 0, fontSize: 16, color: '#0f172a' }}>
{title}
</h3>
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
Research takes 4060 seconds. We search multiple engines (Exa, Tavily), extract key insights,
and assemble a structured research brief. After this, you will move to the <strong>Outline phase</strong>
where AI generates a blog structure, then <strong>Content</strong> writes each section, followed by
<strong> SEO</strong> optimization and <strong>Publish</strong>.
</p>
<div
style={{
marginTop: 14,
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '8px 16px 8px 14px',
gap: 6,
marginTop: 4,
padding: '2px 8px',
borderRadius: 999,
background: statusInfo.background,
color: statusInfo.color,
fontSize: 13,
fontSize: 12,
fontWeight: 600,
border: `1px solid ${statusInfo.color}33`,
animation: isRunning ? 'researchPulse 2s ease-in-out infinite' : undefined
}}
>
{isRunning && (
<CircularProgress
size={14}
thickness={6}
sx={{ color: statusInfo.color }}
/>
)}
<span>{statusInfo.label}</span>
<span style={{ fontSize: 12, color: '#64748b', fontWeight: 500 }}>{statusInfo.description}</span>
{statusInfo.label}
{statusInfo.description && <span style={{ fontWeight: 400, fontSize: 11, color: '#64748b' }}> {statusInfo.description}</span>}
</div>
</div>
<button
onClick={onClose}
style={{
background: '#ffffff',
border: '1px solid #cbd5f5',
borderRadius: 12,
padding: '10px 14px',
cursor: 'pointer',
fontSize: 13,
fontWeight: 600,
color: '#1f2937',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.08)',
transition: 'all 0.2s ease'
}}
>
Close
</button>
</div>
</div>
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
<div style={{ marginBottom: 20 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginBottom: 8
}}
>
<div
style={{
flex: 1,
height: 6,
borderRadius: 3,
background: '#e5e7eb',
overflow: 'hidden',
position: 'relative'
}}
>
<div
style={{
width: `${Math.round((stagesWithState.filter(s => s.state === 'done').length / stagesWithState.length) * 100)}%`,
height: '100%',
borderRadius: 3,
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
transition: 'width 0.5s ease'
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#64748b', whiteSpace: 'nowrap' }}>
{stagesWithState.filter(s => s.state === 'done').length}/{stagesWithState.length}
</span>
</div>
<div
<button
onClick={onClose}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12
background: '#fff',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '6px 12px',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
color: '#475569',
flexShrink: 0
}}
>
Close
</button>
</div>
<div style={{ padding: '12px 20px', overflow: 'auto', flex: 1 }}>
{/* Progress bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: '#e5e7eb', overflow: 'hidden' }}>
<div
style={{
width: `${Math.round((stagesWithState.filter(s => s.state === 'done').length / stagesWithState.length) * 100)}%`,
height: '100%',
borderRadius: 2,
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
transition: 'width 0.5s ease'
}}
/>
</div>
<span style={{ fontSize: 11, fontWeight: 600, color: '#64748b' }}>
{stagesWithState.filter(s => s.state === 'done').length}/{stagesWithState.length}
</span>
</div>
{/* Compact stage indicators */}
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
{stagesWithState.map(stage => {
const copy = stageStateCopy[stage.state];
const isActive = stage.state === 'active';
return (
<div
key={stage.id}
style={{
flex: '1 1 180px',
minWidth: 180,
borderRadius: 14,
padding: '14px 16px',
flex: 1,
padding: '6px 4px',
borderRadius: 8,
background: copy.background,
border: `1px solid ${copy.border}`,
boxShadow: isActive
? '0 0 0 1px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255,255,255,0.6)'
: 'inset 0 1px 0 rgba(255,255,255,0.6)',
animation: isActive ? 'researchPulse 2s ease-in-out infinite' : undefined,
textAlign: 'center',
animation: stage.state === 'active' ? 'researchPulse 2s ease-in-out infinite' : undefined,
transition: 'all 0.3s ease'
}}
>
<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, display: 'flex', alignItems: 'center', gap: 6 }}>
{isActive && (
<CircularProgress size={10} thickness={6} sx={{ color: copy.color }} />
)}
{copy.label}
<div style={{ fontSize: 16, lineHeight: 1 }}>{stage.icon}</div>
<div style={{ fontSize: 10, fontWeight: 600, color: copy.color, marginTop: 2, lineHeight: 1.2 }}>
{stage.state === 'active' ? 'Working…' : stage.state === 'done' ? 'Done' : stage.state === 'error' ? 'Error' : stage.label.split('(')[0].trim()}
</div>
</div>
);
})}
</div>
</div>
{/* Latest message card — compact */}
{latestMessage && (
<div
style={{
borderRadius: 16,
padding: '18px 20px',
borderRadius: 10,
padding: '10px 14px',
border: `1px solid ${toneStyles[latestMessage.tone].border}`,
background: toneStyles[latestMessage.tone].bg,
marginBottom: 20,
boxShadow: '0 4px 16px rgba(15, 23, 42, 0.08)'
marginBottom: 10,
display: 'flex',
alignItems: 'center',
gap: 10
}}
>
<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', display: 'flex', alignItems: 'center', gap: 8 }}>
{latestMessage.tone === 'active' && isRunning && (
<CircularProgress size={14} thickness={6} sx={{ color: '#1d4ed8', flexShrink: 0 }} />
)}
{latestMessage.title}
</div>
<div style={{ fontSize: 12, color: '#64748b', flexShrink: 0 }}>{latestMessage.timeLabel}</div>
<div style={{ fontSize: 20, flexShrink: 0 }}>{latestMessage.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#0f172a', display: 'flex', alignItems: 'center', gap: 6 }}>
{latestMessage.tone === 'active' && isRunning && (
<CircularProgress size={12} thickness={5} sx={{ color: '#1d4ed8', flexShrink: 0 }} />
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{latestMessage.title}</span>
</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 style={{ fontSize: 11, color: '#94a3b8', flexShrink: 0 }}>{latestMessage.timeLabel}</div>
</div>
{latestMessage.subtitle && (
<div style={{ marginTop: 2, fontSize: 12, color: '#64748b', lineHeight: 1.3 }}>{latestMessage.subtitle}</div>
)}
</div>
</div>
)}
{/* Scrollable message log — compact rows */}
<div
style={{
border: '1px solid #e2e8f0',
borderRadius: 16,
padding: '18px 0',
maxHeight: '32vh',
border: '1px solid #e5e7eb',
borderRadius: 10,
maxHeight: '28vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
@@ -765,15 +691,15 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
ref={scrollRef}
style={{
overflowY: 'auto',
padding: '0 20px',
padding: '6px 10px',
display: 'flex',
flexDirection: 'column',
gap: 12
gap: 4
}}
>
{processedMessages.length === 0 && (
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
{isRunning && <CircularProgress size={12} thickness={6} sx={{ color: '#6b7280' }} />}
<div style={{ padding: '8px 0', color: '#9ca3af', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
{isRunning && <CircularProgress size={10} thickness={5} sx={{ color: '#9ca3af' }} />}
Awaiting progress updates
</div>
)}
@@ -784,33 +710,18 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
key={`${meta.timestamp}-${index}`}
style={{
display: 'flex',
gap: 14,
padding: '12px 14px',
borderRadius: 12,
alignItems: 'center',
gap: 8,
padding: '4px 8px',
borderRadius: 6,
background: styles.bg,
border: `1px solid ${styles.border}`
border: `1px solid ${styles.border}`,
fontSize: 12
}}
>
<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>
<span style={{ fontSize: 14, flexShrink: 0 }}>{meta.icon}</span>
<span style={{ fontWeight: 600, color: styles.text, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{meta.title}</span>
<span style={{ color: '#94a3b8', fontSize: 10, flexShrink: 0 }}>{meta.timeLabel}</span>
</div>
);
})}
@@ -820,13 +731,13 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
{error && (
<div
style={{
marginTop: 18,
padding: '12px 16px',
borderRadius: 12,
marginTop: 10,
padding: '8px 12px',
borderRadius: 8,
border: '1px solid #fecaca',
background: '#fef2f2',
color: '#b91c1c',
fontSize: 13.5
fontSize: 13
}}
>
Error: {error}

View File

@@ -88,12 +88,49 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
}
} as const;
const getScoreColor = (score: number) => {
if (score >= 80) return '#16a34a';
if (score >= 60) return '#ca8a04';
return '#dc2626';
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return null;
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
};
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Core SEO Metadata
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
<SearchIcon sx={{ color: 'primary.main' }} />
Core SEO Metadata
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{metadata.optimization_score != null && (
<Tooltip title="AI-generated metadata quality score">
<Chip
label={`Score: ${metadata.optimization_score}/100`}
size="small"
sx={{
fontWeight: 600,
color: '#fff',
bgcolor: getScoreColor(metadata.optimization_score),
}}
/>
</Tooltip>
)}
{metadata.generated_at && (
<Typography variant="caption" sx={{ color: '#5f6368' }}>
Generated: {formatDate(metadata.generated_at)}
</Typography>
)}
</Box>
</Box>
<Grid container spacing={3}>
{/* SEO Title */}
@@ -113,6 +150,34 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</IconButton>
</Tooltip>
</Box>
{/* Title options selector */}
{metadata.title_options && metadata.title_options.length > 1 && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block', fontWeight: 500 }}>
Choose a title option:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{metadata.title_options.map((opt: string, idx: number) => (
<Chip
key={idx}
label={opt}
size="small"
variant={metadata.seo_title === opt ? 'filled' : 'outlined'}
onClick={() => onMetadataEdit('seo_title', opt)}
sx={{
justifyContent: 'flex-start',
height: 'auto',
py: 0.5,
'& .MuiChip-label': { whiteSpace: 'normal', display: 'block' },
...(metadata.seo_title === opt ? { bgcolor: '#e8f5e9', color: '#2e7d32', fontWeight: 600 } : {}),
}}
/>
))}
</Box>
</Box>
)}
<TextField
fullWidth
multiline
@@ -157,6 +222,34 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
</IconButton>
</Tooltip>
</Box>
{/* Meta description options selector */}
{metadata.meta_descriptions && metadata.meta_descriptions.length > 1 && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block', fontWeight: 500 }}>
Choose a description option:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{metadata.meta_descriptions.map((opt: string, idx: number) => (
<Chip
key={idx}
label={opt}
size="small"
variant={metadata.meta_description === opt ? 'filled' : 'outlined'}
onClick={() => onMetadataEdit('meta_description', opt)}
sx={{
justifyContent: 'flex-start',
height: 'auto',
py: 0.5,
'& .MuiChip-label': { whiteSpace: 'normal', display: 'block' },
...(metadata.meta_description === opt ? { bgcolor: '#e8f5e9', color: '#2e7d32', fontWeight: 600 } : {}),
}}
/>
))}
</Box>
</Box>
)}
<TextField
fullWidth
multiline

View File

@@ -247,23 +247,20 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f2f3f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #dadde1'
}}>
{metadata.open_graph?.image ? (
{/* Image */}
<Box sx={{ height: 262, bgcolor: '#f2f3f5', borderBottom: '1px solid #dadde1', position: 'relative', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography variant="caption" sx={{ color: '#65676b' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#65676b' }}>
No image set
{metadata.open_graph?.image ? 'Loading image...' : 'No image set'}
</Typography>
</Box>
{metadata.open_graph?.image && (
<Box
component="img"
src={metadata.open_graph.image}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
</Box>
@@ -347,23 +344,20 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
}}
>
<CardContent sx={{ p: 0 }}>
{/* Image placeholder */}
<Box sx={{
height: 262,
bgcolor: '#f7f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #eff3f4'
}}>
{metadata.twitter_card?.image ? (
{/* Image */}
<Box sx={{ height: 262, bgcolor: '#f7f9fa', borderBottom: '1px solid #eff3f4', position: 'relative', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography variant="caption" sx={{ color: '#536471' }}>
Image loaded
</Typography>
) : (
<Typography variant="caption" sx={{ color: '#536471' }}>
No image set
{metadata.twitter_card?.image ? 'Loading image...' : 'No image set'}
</Typography>
</Box>
{metadata.twitter_card?.image && (
<Box
component="img"
src={metadata.twitter_card.image}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
</Box>

View File

@@ -208,6 +208,27 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
)
}}
/>
{openGraph.image && (
<Box
component="img"
src={openGraph.image}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = 'none';
(e.currentTarget.nextElementSibling as HTMLElement)?.style.removeProperty('display');
}}
sx={{
mt: 1, height: 120, width: '100%', objectFit: 'cover', borderRadius: 1,
border: '1px solid', borderColor: 'divider', display: 'block'
}}
/>
)}
<Box sx={{
mt: 1, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 1, border: '1px dashed', borderColor: 'divider', bgcolor: 'grey.50',
...(openGraph.image ? { display: 'none' } : {})
}}>
<Typography variant="caption" color="text.secondary">No preview</Typography>
</Box>
</Grid>
<Grid item xs={12} md={6}>
@@ -359,6 +380,27 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
)
}}
/>
{twitterCard.image && (
<Box
component="img"
src={twitterCard.image}
onError={(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = 'none';
(e.currentTarget.nextElementSibling as HTMLElement)?.style.removeProperty('display');
}}
sx={{
mt: 1, height: 120, width: '100%', objectFit: 'cover', borderRadius: 1,
border: '1px solid', borderColor: 'divider', display: 'block'
}}
/>
)}
<Box sx={{
mt: 1, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 1, border: '1px dashed', borderColor: 'divider', bgcolor: 'grey.50',
...(twitterCard.image ? { display: 'none' } : {})
}}>
<Typography variant="caption" color="text.secondary">No preview</Typography>
</Box>
</Grid>
<Grid item xs={12} md={6}>

View File

@@ -20,10 +20,6 @@ import {
Tooltip,
InputAdornment,
Alert,
Card,
CardContent,
Divider,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
@@ -137,7 +133,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
return (
<Box>
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CodeIcon sx={{ color: 'primary.main' }} />
Structured Data (JSON-LD)
</Typography>
@@ -145,16 +141,16 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid container spacing={3}>
{/* Article Information */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 2, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CodeIcon />
Article Schema
</Typography>
<Grid container spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Headline
</Typography>
<Tooltip title="Copy to clipboard">
@@ -189,7 +185,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Description
</Typography>
<Tooltip title="Copy to clipboard">
@@ -204,7 +200,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<TextField
fullWidth
multiline
rows={2}
rows={1}
value={jsonLdSchema.description || ''}
onChange={handleSchemaFieldChange('description')}
placeholder="Article description"
@@ -226,7 +222,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Main Entity URL
</Typography>
<Tooltip title="Copy to clipboard">
@@ -255,8 +251,8 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Word Count
</Typography>
<Tooltip title="Copy to clipboard">
@@ -286,16 +282,16 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Author Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 2, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<PersonIcon />
Author Information
</Typography>
<Grid container spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Author Name
</Typography>
<Tooltip title="Copy to clipboard">
@@ -318,7 +314,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Author Type
</Typography>
<Tooltip title="Copy to clipboard">
@@ -344,16 +340,16 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publisher Information */}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 2, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<BusinessIcon />
Publisher Information
</Typography>
<Grid container spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Publisher Name
</Typography>
<Tooltip title="Copy to clipboard">
@@ -376,7 +372,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Publisher Logo
</Typography>
<Tooltip title="Copy to clipboard">
@@ -402,16 +398,16 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Publication Dates */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 2, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CalendarIcon />
Publication Dates
</Typography>
<Grid container spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Date Published
</Typography>
<Tooltip title="Copy to clipboard">
@@ -435,7 +431,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Date Modified
</Typography>
<Tooltip title="Copy to clipboard">
@@ -462,16 +458,16 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
{/* Keywords */}
<Grid item xs={12}>
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
<Paper sx={{ p: 2, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
<CodeIcon />
Keywords & Categories
</Typography>
<Grid container spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Keywords
</Typography>
<Tooltip title="Copy to clipboard">
@@ -507,7 +503,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
Raw JSON-LD Schema
</Typography>
</Box>
@@ -515,7 +511,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
<AccordionDetails>
<Box sx={{ position: 'relative' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
Complete JSON-LD Schema
</Typography>
<Button

View File

@@ -142,6 +142,8 @@ interface SEOAnalysisModalProps {
blogContent: string;
blogTitle?: string;
researchData: any;
outline?: any[];
competitiveAdvantage?: string;
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
@@ -154,6 +156,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
blogContent,
blogTitle,
researchData,
outline,
competitiveAdvantage,
onApplyRecommendations,
onAnalysisComplete
}) => {
@@ -246,7 +250,9 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const responsePromise = apiClient.post('/api/blog-writer/seo/analyze', {
blog_content: blogContent,
blog_title: blogTitle,
research_data: researchData
research_data: researchData,
outline: outline || undefined,
competitive_advantage: competitiveAdvantage || undefined,
}, { timeout: 120000 });
// Simulated progress runs alongside the API call to keep the user informed.
@@ -293,13 +299,13 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
ai_insights: result.category_scores?.ai_insights || 0
},
analysis_summary: result.analysis_summary || {
overall_grade: result.overall_score >= 80 ? 'A' : result.overall_score >= 60 ? 'B' : 'C',
status: result.overall_score >= 80 ? 'Excellent' : result.overall_score >= 60 ? 'Good' : 'Needs Improvement',
strongest_category: 'structure',
weakest_category: 'keywords',
key_strengths: ['Good content structure', 'Appropriate length'],
key_weaknesses: ['Keyword optimization needs work'],
ai_summary: 'Content provides good value with room for SEO improvements.'
overall_grade: result.overall_score >= 90 ? 'A' : result.overall_score >= 80 ? 'B' : result.overall_score >= 70 ? 'C' : result.overall_score >= 60 ? 'D' : 'F',
status: result.overall_score >= 90 ? 'Excellent' : result.overall_score >= 80 ? 'Good' : result.overall_score >= 70 ? 'Fair' : result.overall_score >= 60 ? 'Needs Improvement' : 'Poor',
strongest_category: result.category_scores ? Object.entries(result.category_scores).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] || 'structure' : 'structure',
weakest_category: result.category_scores ? Object.entries(result.category_scores).sort((a: any, b: any) => a[1] - b[1])[0]?.[0] || 'keywords' : 'keywords',
key_strengths: [],
key_weaknesses: [],
ai_summary: ''
},
actionable_recommendations: (result.actionable_recommendations || []).map((rec: any) => ({
category: rec.category || 'General',

View File

@@ -36,7 +36,7 @@ import {
Tag as TagIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { apiClient, triggerSubscriptionError } from '../../api/client';
import { aiApiClient, triggerSubscriptionError } from '../../api/client';
// Import metadata display components
import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
@@ -51,8 +51,9 @@ interface SEOMetadataModalProps {
blogContent: string;
blogTitle: string;
researchData: any;
outline?: any[]; // Add outline structure
seoAnalysis?: any; // Add SEO analysis results
outline?: any[];
seoAnalysis?: any;
sectionImages?: Record<string, string>;
onMetadataGenerated: (metadata: any) => void;
}
@@ -102,6 +103,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
researchData,
outline,
seoAnalysis,
sectionImages,
onMetadataGenerated
}) => {
const [isGenerating, setIsGenerating] = useState(false);
@@ -166,6 +168,55 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const cacheKey = getMetadataCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO metadata cache', { cacheKey, hasHash: !!hash, forceRefresh });
// Define early so both cache and API paths can use it
const sanitizeMetadata = (data: any) => {
const safe = { ...data };
safe.seo_title = safe.seo_title ?? '';
safe.meta_description = safe.meta_description ?? '';
safe.url_slug = safe.url_slug ?? '';
safe.focus_keyword = safe.focus_keyword ?? '';
safe.reading_time = typeof safe.reading_time === 'number' ? safe.reading_time : 0;
safe.blog_tags = Array.isArray(safe.blog_tags) ? safe.blog_tags : [];
safe.blog_categories = Array.isArray(safe.blog_categories) ? safe.blog_categories : [];
safe.social_hashtags = Array.isArray(safe.social_hashtags) ? safe.social_hashtags : [];
safe.open_graph = {
...(safe.open_graph || {}),
title: safe.open_graph?.title ?? '',
description: safe.open_graph?.description ?? '',
image: safe.open_graph?.image ?? '',
url: safe.open_graph?.url ?? ''
};
safe.twitter_card = {
...(safe.twitter_card || {}),
title: safe.twitter_card?.title ?? '',
description: safe.twitter_card?.description ?? '',
image: safe.twitter_card?.image ?? '',
site: safe.twitter_card?.site ?? ''
};
safe.json_ld_schema = { ...(safe.json_ld_schema || {}) };
const firstSectionImage = (() => {
try {
const images = sectionImages && Object.keys(sectionImages).length > 0
? sectionImages
: JSON.parse(localStorage.getItem('blog_section_images') || '{}');
const values = Object.values(images).filter(Boolean);
return values.length > 0 ? String(values[0]) : null;
} catch { return null; }
})();
if (firstSectionImage) {
const isPlaceholder = (url: string) => !url || url === 'https://example.com/image.jpg' || url.includes('example.com') || url.includes('placeholder');
if (isPlaceholder(safe.open_graph?.image || '')) {
safe.open_graph = { ...safe.open_graph, image: firstSectionImage };
}
if (isPlaceholder(safe.twitter_card?.image || '')) {
safe.twitter_card = { ...safe.twitter_card, image: firstSectionImage };
}
}
return safe;
};
// Check cache first (unless force refresh)
if (!forceRefresh && typeof window !== 'undefined') {
const cached = window.localStorage.getItem(cacheKey);
@@ -175,12 +226,13 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// 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);
const sanitized = sanitizeMetadata(parsed);
setMetadataResult(sanitized);
setEditableMetadata(sanitized);
setIsGenerating(false);
// Notify parent that metadata is available
if (onMetadataGenerated) {
onMetadataGenerated(parsed);
onMetadataGenerated(sanitized);
}
return;
} else {
@@ -201,7 +253,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
// Make API call to generate metadata
const response = await apiClient.post('/api/blog/seo/metadata', {
const response = await aiApiClient.post('/api/blog/seo/metadata', {
content: blogContent,
title: blogTitle,
research_data: researchData,
@@ -267,33 +319,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}
const sanitizeMetadata = (data: any) => {
const safe = { ...data };
safe.seo_title = safe.seo_title ?? '';
safe.meta_description = safe.meta_description ?? '';
safe.url_slug = safe.url_slug ?? '';
safe.focus_keyword = safe.focus_keyword ?? '';
safe.reading_time = typeof safe.reading_time === 'number' ? safe.reading_time : 0;
safe.blog_tags = Array.isArray(safe.blog_tags) ? safe.blog_tags : [];
safe.blog_categories = Array.isArray(safe.blog_categories) ? safe.blog_categories : [];
safe.social_hashtags = Array.isArray(safe.social_hashtags) ? safe.social_hashtags : [];
safe.open_graph = {
...(safe.open_graph || {}),
title: safe.open_graph?.title ?? '',
description: safe.open_graph?.description ?? '',
image: safe.open_graph?.image ?? '',
url: safe.open_graph?.url ?? ''
};
safe.twitter_card = {
...(safe.twitter_card || {}),
title: safe.twitter_card?.title ?? '',
description: safe.twitter_card?.description ?? '',
image: safe.twitter_card?.image ?? '',
site: safe.twitter_card?.site ?? ''
};
safe.json_ld_schema = { ...(safe.json_ld_schema || {}) };
return safe;
};
const sanitized = sanitizeMetadata(result);
setMetadataResult(sanitized);
setEditableMetadata(sanitized);
@@ -364,6 +389,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata');
setIsGenerating(false);
} finally {
setIsGenerating(false);
}
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
@@ -490,7 +517,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TagIcon sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
SEO Metadata Generator
</Typography>
{metadataResult && (
@@ -617,6 +644,14 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
{metadataResult && (
<DialogActions sx={{ p: 3, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
<Button
variant="outlined"
onClick={() => generateMetadata(true)}
startIcon={<RefreshIcon />}
sx={{ mr: 'auto' }}
>
Regenerate
</Button>
<Button onClick={onClose} color="inherit">
Cancel
</Button>

View File

@@ -6,6 +6,7 @@ interface SectionGeneratorProps {
outline: BlogOutlineSection[];
research: BlogResearchResponse | null;
genMode: 'draft' | 'polished';
competitiveAdvantage?: string;
onSectionGenerated: (sectionId: string, markdown: string) => void;
onContinuityRefresh: () => void;
navigateToPhase?: (phase: string) => void;
@@ -17,6 +18,7 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
outline,
research,
genMode,
competitiveAdvantage,
onSectionGenerated,
onContinuityRefresh,
navigateToPhase
@@ -33,7 +35,7 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
navigateToPhase?.('content');
try {
const res = await blogWriterApi.generateSection({ section, mode: genMode });
const res = await blogWriterApi.generateSection({ section, mode: genMode, research: research || undefined, competitive_advantage: competitiveAdvantage });
if (res?.markdown) {
onSectionGenerated(sectionId, res.markdown);
onContinuityRefresh();
@@ -107,7 +109,7 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
navigateToPhase?.('content');
for (const s of outline) {
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
const res = await blogWriterApi.generateSection({ section: s, mode: genMode, research: research || undefined, competitive_advantage: competitiveAdvantage });
onSectionGenerated(s.id, res.markdown);
onContinuityRefresh();
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Grid,
@@ -47,7 +48,9 @@ import {
Timeline as TimelineIcon,
Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon,
AutoAwesome as AutoAwesomeIcon
AutoAwesome as AutoAwesomeIcon,
LinkedIn as LinkedInIcon,
Description as DescriptionIcon
} from '@mui/icons-material';
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
@@ -74,6 +77,7 @@ function TabPanel(props: TabPanelProps) {
}
const CalendarTab: React.FC = () => {
const navigate = useNavigate();
const {
calendarEvents,
createEvent,
@@ -539,6 +543,44 @@ const CalendarTab: React.FC = () => {
<Typography variant="caption" color="text.secondary">
Scheduled: {new Date(event.scheduled_date || event.date || '').toLocaleDateString()}
</Typography>
{event.status !== 'published' && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
{(event.platform === 'linkedin') && (
<Button
size="small"
variant="contained"
color="primary"
startIcon={<LinkedInIcon />}
onClick={() => navigate('/linkedin', {
state: {
calendarTopic: event.title,
calendarDescription: event.description,
calendarEventId: event.id
}
})}
>
LinkedIn Post
</Button>
)}
{(event.content_type === 'blog_post' || event.content_type === 'blog') && (
<Button
size="small"
variant="contained"
color="secondary"
startIcon={<DescriptionIcon />}
onClick={() => navigate('/blog', {
state: {
calendarTopic: event.title,
calendarDescription: event.description,
calendarEventId: event.id
}
})}
>
Blog Post
</Button>
)}
</Box>
)}
</CardContent>
</Card>
</Grid>

View File

@@ -30,6 +30,7 @@ import {
TrendingFlat as TrendFlatIcon,
GpsFixed as GapIcon,
BarChart as VolumeIcon,
CalendarMonth as CalendarIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useWorkflowStore } from '../../../stores/workflowStore';
@@ -85,11 +86,19 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
if (task.action) {
task.action();
} else if (task.actionUrl) {
navigate(task.actionUrl);
const navigationState: Record<string, any> = {
workflowTaskId: task.id
};
if (task.metadata?.source === 'calendar_event') {
navigationState.calendarEventId = task.metadata.source_event_id;
navigationState.calendarTopic = task.title;
navigationState.calendarDescription = task.description;
}
navigate(task.actionUrl, { state: navigationState });
}
// Mark task as completed in workflow
if (currentWorkflow) {
// Mark task as completed in workflow (skip for calendar tasks — writers handle it after save/publish)
if (currentWorkflow && task.metadata?.source !== 'calendar_event') {
await completeTask(task.id);
}
} catch (error) {
@@ -364,6 +373,26 @@ const getTaskStatus = (task: TodayTask) => {
{pillarTitle} Tasks
</Typography>
{pillarTasks.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CalendarIcon sx={{ fontSize: 48, color: '#ccc', mb: 2 }} />
<Typography variant="body1" sx={{ color: '#5A5F6A', mb: 1 }}>
No content scheduled for this pillar today
</Typography>
<Typography variant="body2" sx={{ color: '#999', mb: 2 }}>
Add content to your Content Calendar to populate workflow tasks
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<CalendarIcon />}
onClick={onClose}
sx={{ borderColor: pillarColor, color: pillarColor }}
>
Go to Calendar
</Button>
</Box>
) : (
<Stack spacing={2}>
{pillarTasks.map((task, index) => {
const status = getTaskStatus(task);
@@ -430,6 +459,28 @@ const getTaskStatus = (task: TodayTask) => {
</Box>
</Box>
{/* Calendar Event Source Badge */}
{task.metadata?.source === 'calendar_event' && (
<Box sx={{
mt: 1.5,
mb: 1.5,
p: 1.5,
bgcolor: '#e8f4fd',
borderRadius: 2,
border: '1px solid #b3d9f2',
display: 'flex',
alignItems: 'flex-start',
gap: 1.5
}}>
<CalendarIcon sx={{ fontSize: 16, color: '#1976d2', mt: 0.3 }} />
<Box sx={{ flexGrow: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1565c0' }}>
From your Content Calendar
</Typography>
</Box>
</Box>
)}
{/* Agent Reasoning Section */}
{task.metadata?.source_agent && (
<Box sx={{
@@ -528,6 +579,7 @@ const getTaskStatus = (task: TodayTask) => {
);
})}
</Stack>
)}
</Box>
{/* Footer Actions */}

View File

@@ -198,8 +198,8 @@ export const PlatformPersonaChat: React.FC<PlatformPersonaChatProps> = ({
return `${systemMessage}\n\nCurrent Context: ${contextString}`;
}, [systemMessage]);
// Loading state
if (loading) {
// Loading state — only block if persona data is not yet available
if (loading && !corePersona) {
return (
<div className="flex items-center justify-center p-6 border rounded-lg">
<div className="text-center">
@@ -210,8 +210,8 @@ export const PlatformPersonaChat: React.FC<PlatformPersonaChatProps> = ({
);
}
// Error state
if (error) {
// Error state — only block if persona data is missing; with defaults, continue normally
if (error && !corePersona) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">

View File

@@ -4,7 +4,7 @@
* Integrates with existing persona API client and injects data into CopilotKit
*/
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback, useRef } from 'react';
import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode, useCallback, useRef } from 'react';
import { useCopilotReadable } from '@copilotkit/react-core';
import { useAuth } from '@clerk/clerk-react';
import {
@@ -16,6 +16,125 @@ import {
getUserPersonas,
getPlatformPersona
} from '../../../api/persona';
import { shouldSkipOnboarding } from '../../../utils/demoMode';
const LINKEDIN_DEFAULT_CORE_PERSONA: WritingPersona = {
id: 0,
user_id: 0,
persona_name: 'LinkedIn Professional',
archetype: 'Thought Leader',
core_belief: 'Sharing knowledge drives professional growth',
brand_voice_description: 'Clear, authoritative, and approachable',
linguistic_fingerprint: {
sentence_metrics: {
average_sentence_length_words: 15,
preferred_sentence_type: 'compound',
active_to_passive_ratio: '80:20',
sentence_complexity: 'moderate',
paragraph_structure: 'standard',
},
lexical_features: {
go_to_words: ['leverage', 'optimize', 'strategic'],
go_to_phrases: ["Let's explore", "Here's the thing"],
avoid_words: ['utilize', 'synergize'],
contractions: 'moderate',
vocabulary_level: 'professional',
industry_terminology: [],
emotional_tone_words: [],
},
rhetorical_devices: {
metaphors: 'tech_mechanics',
analogies: 'everyday_to_tech',
rhetorical_questions: 'occasional',
storytelling_approach: 'case_study',
persuasion_techniques: ['logic', 'credibility'],
},
},
platform_adaptations: [],
onboarding_session_id: 0,
source_website_analysis: {},
source_research_preferences: {},
ai_analysis_version: '1.0',
confidence_score: 0.5,
analysis_date: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
is_active: true,
};
const LINKEDIN_DEFAULT_PLATFORM_PERSONA: PlatformAdaptation = {
id: 0,
writing_persona_id: 0,
platform_type: 'linkedin',
sentence_metrics: {
optimal_length: '150-300 words',
character_limit: 3000,
sentence_structure: 'varied',
paragraph_breaks: 'frequent',
readability_score: 8.5,
},
lexical_features: {
hashtag_strategy: '3-5 relevant hashtags',
platform_specific_terms: [],
engagement_phrases: ['What do you think?', 'Share your thoughts'],
call_to_action_style: 'gentle',
},
rhetorical_devices: {
question_frequency: 'occasional',
story_elements: 'personal_anecdotes',
visual_descriptions: 'minimal',
interactive_elements: 'questions',
},
tonal_range: {
default_tone: 'professional_friendly',
permissible_tones: ['inspiring', 'thoughtful'],
forbidden_tones: ['salesy', 'academic'],
emotional_range: 'moderate',
formality_level: 'semi_formal',
},
stylistic_constraints: {
punctuation_preferences: 'standard',
formatting_rules: 'clean',
emoji_usage: 'minimal',
link_placement: 'end',
media_integration: 'encouraged',
},
content_format_rules: {
character_limit: 3000,
optimal_length: '150-300 words',
word_count: '150-300',
hashtag_limit: 3,
media_requirements: 'optional',
link_restrictions: 'unlimited',
},
engagement_patterns: {
posting_frequency: '2-3 times per week',
best_timing: '9 AM - 11 AM, 1 PM - 3 PM',
interaction_style: 'conversational',
response_strategy: 'within 2 hours',
community_approach: 'collaborative',
},
posting_frequency: {
frequency: '2-3 times per week',
optimal_days: ['Tuesday', 'Wednesday', 'Thursday'],
optimal_times: ['9:00 AM', '1:00 PM'],
seasonal_adjustments: 'moderate',
},
content_types: {
primary_content: ['thought_leadership', 'industry_insights'],
secondary_content: ['personal_stories', 'tips'],
content_mix: '70% professional, 30% personal',
seasonal_content: ['trending_topics', 'industry_events'],
},
platform_best_practices: {
algorithm_tips: ['post_consistently', 'engage_with_community'],
engagement_tactics: ['ask_questions', 'share_stories'],
content_strategies: ['value_first', 'authentic_voice'],
growth_hacks: ['cross_promotion', 'collaboration'],
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Context interface
interface PlatformPersonaContextType {
@@ -39,20 +158,118 @@ interface PlatformPersonaProviderProps {
// Cache duration: 5 minutes (constant outside component to avoid dependency issues)
const CACHE_DURATION = 5 * 60 * 1000;
/**
* Internal component that injects persona data into CopilotKit context.
* Rendered only when skipOnboarding is false — when feature-gated,
* we skip useCopilotReadable entirely to avoid cascading re-renders
* with child components that also call useCopilotReadable.
*/
const PersonaCopilotInjector: React.FC<{
corePersona: WritingPersona | null;
platformPersona: PlatformAdaptation | null;
platform: PlatformType;
children: ReactNode;
}> = ({ corePersona, platformPersona, platform, children }) => {
const corePersonaCategories = useMemo(() => ["core-persona", "writing-style", "user-preferences"], []);
const platformPersonaCategories = useMemo(() => ["platform-persona", platform, "content-optimization"], [platform]);
const combinedPersonaCategories = useMemo(() => ["complete-persona", platform, "writing-guidance"], [platform]);
const corePersonaDescription = useMemo(
() => `Core writing persona: ${corePersona?.persona_name || 'Loading...'}`,
[corePersona?.persona_name]
);
const platformPersonaDescription = useMemo(
() => `${platform} platform optimization rules and constraints`,
[platform]
);
const combinedPersonaDescription = useMemo(
() => `Complete ${platform} writing persona with linguistic fingerprint and platform optimization`,
[platform]
);
const corePersonaParentId = useMemo(
() => corePersona?.id?.toString(),
[corePersona?.id]
);
useCopilotReadable({
description: corePersonaDescription,
value: corePersona,
categories: corePersonaCategories,
parentId: corePersonaParentId
}, [corePersona]);
useEffect(() => {
if (corePersona) {
console.log('🎯 Injected core persona into CopilotKit:', {
name: corePersona.persona_name,
archetype: corePersona.archetype,
confidence: corePersona.confidence_score,
hasLinguisticFingerprint: !!(corePersona.linguistic_fingerprint && Object.keys(corePersona.linguistic_fingerprint).length)
});
}
}, [corePersona]);
useCopilotReadable({
description: platformPersonaDescription,
value: platformPersona,
categories: platformPersonaCategories,
parentId: corePersonaParentId
}, [platformPersona]);
useEffect(() => {
if (platformPersona) {
console.log('🎯 Injected platform persona into CopilotKit:', {
platform: platformPersona.platform_type,
characterLimit: platformPersona.content_format_rules?.character_limit,
optimalLength: platformPersona.content_format_rules?.optimal_length
});
}
}, [platformPersona]);
const combinedPersonaValue = useMemo(() => ({
core: corePersona,
platform: platformPersona,
combined: {
persona_name: corePersona?.persona_name,
archetype: corePersona?.archetype,
platform: platform,
linguistic_fingerprint: corePersona?.linguistic_fingerprint,
platform_constraints: platformPersona?.content_format_rules,
engagement_patterns: platformPersona?.engagement_patterns
}
}), [corePersona, platformPersona, platform]);
useCopilotReadable({
description: combinedPersonaDescription,
value: combinedPersonaValue,
categories: combinedPersonaCategories,
parentId: corePersonaParentId
}, [corePersona, platformPersona, platform]);
return <>{children}</>;
};
// Provider component
export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = ({
children,
platform
}) => {
const skipOnboarding = shouldSkipOnboarding();
// Get Clerk user ID
const { userId } = useAuth();
// Convert string userId to number for legacy API compatibility
const numericUserId = userId ? 1 : 1; // Use 1 as placeholder, API uses Clerk ID from auth
// State management
const [corePersona, setCorePersona] = useState<WritingPersona | null>(null);
const [platformPersona, setPlatformPersona] = useState<PlatformAdaptation | null>(null);
const [loading, setLoading] = useState(true);
// State management — seed defaults immediately in feature-gated mode
const [corePersona, setCorePersona] = useState<WritingPersona | null>(
skipOnboarding ? LINKEDIN_DEFAULT_CORE_PERSONA : null
);
const [platformPersona, setPlatformPersona] = useState<PlatformAdaptation | null>(
skipOnboarding ? LINKEDIN_DEFAULT_PLATFORM_PERSONA : null
);
const [loading, setLoading] = useState(!skipOnboarding);
const [error, setError] = useState<string | null>(null);
// Add request throttling
@@ -62,6 +279,12 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
// Fetch persona data function
const fetchPersonas = useCallback(async () => {
// In feature-gated mode, skip API calls entirely — defaults already seeded
if (skipOnboarding) {
setLoading(false);
return;
}
const now = Date.now();
// Prevent multiple simultaneous requests
@@ -86,8 +309,6 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
setLoading(true);
setError(null);
// Fetch both core persona and platform-specific data
// Note: APIs use Clerk auth, so user ID is extracted from JWT
let userPersonasResponse;
let platformPersonaResponse = null;
@@ -95,7 +316,6 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
const results = await Promise.all([
getUserPersonas(),
getPlatformPersona(platform).catch(err => {
// Handle 404 gracefully - platform persona doesn't exist yet
if (err.message && err.message.includes('No persona found')) {
console.warn(`⚠️ No ${platform} persona found - user can still generate content`);
return null;
@@ -106,10 +326,11 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
userPersonasResponse = results[0];
platformPersonaResponse = results[1];
} catch (error) {
// If platform persona fetch fails, continue with core persona only
console.warn(`⚠️ Platform persona unavailable: ${error instanceof Error ? error.message : 'Unknown error'}`);
userPersonasResponse = await getUserPersonas();
platformPersonaResponse = null;
console.warn(`⚠️ Persona API unavailable, using defaults: ${error instanceof Error ? error.message : 'Unknown error'}`);
setCorePersona(LINKEDIN_DEFAULT_CORE_PERSONA);
setPlatformPersona(LINKEDIN_DEFAULT_PLATFORM_PERSONA);
setError(null);
return;
}
// Handle core persona data
@@ -299,12 +520,18 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
} catch (error) {
console.error('❌ Error fetching personas:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch persona data');
// Set fallback data if available
if (corePersona) {
console.log('🔄 Using existing core persona data');
// Provide fallback defaults so children always render
if (!corePersona) {
console.log('🔄 Using default LinkedIn persona as fallback');
setCorePersona(LINKEDIN_DEFAULT_CORE_PERSONA);
}
if (!platformPersona) {
console.log('🔄 Using default LinkedIn platform persona as fallback');
setPlatformPersona(LINKEDIN_DEFAULT_PLATFORM_PERSONA);
}
// Clear error state — with defaults available, consumers should function normally
setError(null);
} finally {
setLoading(false);
lastRequestTime.current = Date.now();
@@ -319,116 +546,31 @@ export const PlatformPersonaProvider: React.FC<PlatformPersonaProviderProps> = (
}, [fetchPersonas]);
// Refresh function for manual updates
const refreshPersonas = async () => {
const refreshPersonas = useCallback(async () => {
await fetchPersonas();
};
}, [fetchPersonas]);
// Inject core persona into CopilotKit context
useCopilotReadable({
description: `Core writing persona: ${corePersona?.persona_name || 'Loading...'}`,
value: corePersona,
categories: ["core-persona", "writing-style", "user-preferences"],
parentId: corePersona?.id?.toString()
});
// Memoize context value to prevent unnecessary re-renders of consumers
const contextValue = useMemo(() => ({
corePersona,
platformPersona,
platform,
loading,
error,
refreshPersonas
}), [corePersona, platformPersona, platform, loading, error, refreshPersonas]);
// Debug: Log when persona data is available for CopilotKit
useEffect(() => {
if (corePersona) {
console.log('🎯 Injected core persona into CopilotKit:', {
name: corePersona.persona_name,
archetype: corePersona.archetype,
confidence: corePersona.confidence_score,
hasLinguisticFingerprint: !!(corePersona.linguistic_fingerprint && Object.keys(corePersona.linguistic_fingerprint).length)
});
}
}, [corePersona]);
// No blocking spinner/error states — children always render.
// Loading/error states are still exposed via context so consumers
// can show non-blocking indicators if they want.
// Inject platform-specific persona into CopilotKit context
useCopilotReadable({
description: `${platform} platform optimization rules and constraints`,
value: platformPersona,
categories: ["platform-persona", platform, "content-optimization"],
parentId: corePersona?.id?.toString()
});
// Debug: Log when platform persona is available for CopilotKit
useEffect(() => {
if (platformPersona) {
console.log('🎯 Injected platform persona into CopilotKit:', {
platform: platformPersona.platform_type,
characterLimit: platformPersona.content_format_rules?.character_limit,
optimalLength: platformPersona.content_format_rules?.optimal_length
});
}
}, [platformPersona]);
// Inject combined persona context for comprehensive understanding
useCopilotReadable({
description: `Complete ${platform} writing persona with linguistic fingerprint and platform optimization`,
value: {
core: corePersona,
platform: platformPersona,
combined: {
persona_name: corePersona?.persona_name,
archetype: corePersona?.archetype,
platform: platform,
linguistic_fingerprint: corePersona?.linguistic_fingerprint,
platform_constraints: platformPersona?.content_format_rules,
engagement_patterns: platformPersona?.engagement_patterns
}
},
categories: ["complete-persona", platform, "writing-guidance"],
parentId: corePersona?.id?.toString()
});
// Loading state
if (loading) {
return (
<div className="flex items-center justify-center p-4">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Loading {platform} persona...</p>
</div>
</div>
);
}
// Error state
if (error && !corePersona) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Failed to load persona data</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
<button
onClick={refreshPersonas}
className="mt-2 text-sm text-red-600 hover:text-red-500 underline"
>
Try again
</button>
</div>
</div>
</div>
);
}
// Success state - provide context
return (
<PlatformPersonaContext.Provider value={{
corePersona,
platformPersona,
platform,
loading,
error,
refreshPersonas
}}>
{children}
<PlatformPersonaContext.Provider value={contextValue}>
{skipOnboarding ? children : (
<PersonaCopilotInjector corePersona={corePersona} platformPersona={platformPersona} platform={platform}>
{children}
</PersonaCopilotInjector>
)}
</PlatformPersonaContext.Provider>
);
};

View File

@@ -28,6 +28,14 @@ const readLSString = (key: string, fallback: string): string => {
}
};
// Compute a content fingerprint from the outline to detect stale SEO metadata.
// The fingerprint changes whenever the blog structure (outline IDs + title) changes,
// which means any previously cached SEO metadata is no longer valid.
const computeMetadataFingerprint = (outline: BlogOutlineSection[], selectedTitle: string): string => {
const ids = outline.map(s => String(s.id)).sort().join(',');
return `${ids}|${selectedTitle}`;
};
const readLSBool = (key: string, fallback: boolean): boolean => {
try {
const raw = localStorage.getItem(key);
@@ -85,16 +93,34 @@ const restoreInitialState = () => {
// Restore confirmation flags
outlineConfirmed = readLSBool('blog_outline_confirmed', false);
// Backward compatibility: if outline exists but confirmation wasn't saved, assume confirmed
if (!outlineConfirmed && outline.length > 0) {
outlineConfirmed = true;
}
contentConfirmed = readLSBool('blog_content_confirmed', false);
// Only restore outline/content confirmation from explicit flags.
// Previously, backward compat assumed confirmed if data existed, but this
// caused premature phase advancement (e.g. jumping to SEO phase on page load)
// when restoring stale cached data from a prior session.
// Restore SEO data
seoAnalysis = readLS<BlogSEOAnalyzeResponse | null>('blog_seo_analysis', null);
seoMetadata = readLS<BlogSEOMetadataResponse | null>('blog_seo_metadata', null);
// Validate SEO metadata against current outline — discard if stale
if (seoMetadata) {
if (outline.length === 0) {
// No outline context — metadata is meaningless for a fresh blog
seoMetadata = null;
try { localStorage.removeItem('blog_seo_metadata'); } catch {}
} else {
const savedFingerprint = readLS<string | null>('blog_seo_metadata_fingerprint', null);
const currentFingerprint = computeMetadataFingerprint(outline, selectedTitle);
if (savedFingerprint !== currentFingerprint) {
// Outline or title changed since metadata was generated — discard stale data
seoMetadata = null;
try { localStorage.removeItem('blog_seo_metadata'); } catch {}
try { localStorage.removeItem('blog_seo_metadata_fingerprint'); } catch {}
}
}
}
// Restore section images (log only once per session, not on every hook mount)
const savedSectionImages = readLS<Record<string, string> | null>('blog_section_images', null);
if (savedSectionImages && Object.keys(savedSectionImages).length > 0) {
@@ -237,14 +263,21 @@ export const useBlogWriterState = () => {
} catch {}
}, [seoAnalysis]);
// Persist seoMetadata to localStorage whenever it changes
// Persist seoMetadata + content fingerprint to localStorage whenever they change
useEffect(() => {
try {
if (seoMetadata) {
localStorage.setItem('blog_seo_metadata', JSON.stringify(seoMetadata));
// Save fingerprint to detect staleness on future page loads
const fingerprint = computeMetadataFingerprint(outline, selectedTitle);
localStorage.setItem('blog_seo_metadata_fingerprint', fingerprint);
} else {
// Clear both when metadata is explicitly nulled
localStorage.removeItem('blog_seo_metadata');
localStorage.removeItem('blog_seo_metadata_fingerprint');
}
} catch {}
}, [seoMetadata]);
}, [seoMetadata, outline, selectedTitle]);
// Persist sectionImages to localStorage whenever they change
useEffect(() => {
@@ -292,6 +325,9 @@ export const useBlogWriterState = () => {
// Handle research completion
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
// New research topic — any prior SEO metadata is now stale
setSeoMetadata(null);
setResearch(researchData);
const formattedAngles = dedupeTitles(
(researchData?.suggested_angles || []).map(formatContentAngleToTitle)
@@ -309,6 +345,9 @@ export const useBlogWriterState = () => {
// Handle outline completion with enhanced metadata
const handleOutlineComplete = useCallback((result: any) => {
if (result?.outline) {
// New content structure — any prior SEO metadata is now stale
setSeoMetadata(null);
setOutline(result.outline);
const aiTitleOptions: string[] = result.title_options || [];
@@ -494,12 +533,12 @@ export const useBlogWriterState = () => {
localStorage.setItem('blog_publish_completed', 'true');
}
// Restore phase
const phase = asset.phase || 'research';
localStorage.setItem('blogwriter_current_phase', phase);
localStorage.setItem('blogwriter_user_selected_phase', 'true');
// Note: Intentionally NOT writing the asset's phase to localStorage here.
// The user's actual phase from their previous session (persisted by
// usePhaseNavigationCore) is more reliable. Writing 'research' here would
// overwrite it and cause usePhaseRestoration to restore the stale phase.
console.log('[BlogWriterState] Restored from asset:', asset.id, 'phase:', phase);
console.log('[BlogWriterState] Restored from asset:', asset.id, 'phase:', asset.phase || 'research');
} catch (e) {
console.error('[BlogWriterState] Failed to restore from asset:', e);
}

View File

@@ -54,7 +54,6 @@ export const usePhaseNavigation = (
const outlineCompleted = outline.length > 0;
const contentCompleted = hasContent && contentConfirmed;
const seoCompleted = !!seoAnalysis && (seoRecommendationsApplied === true || !!seoMetadata);
return [
{
id: 'research',
@@ -149,11 +148,12 @@ export const usePhaseNavigation = (
core.setCurrentPhase('content');
}
} else if (contentConfirmed && !seoAnalysis) {
if (core.currentPhase !== 'seo' && canNavigateTo('seo')) {
// Only auto-advance to SEO if user is already on/past content phase
if ((core.currentPhase === 'content' || core.currentPhase === 'seo') && canNavigateTo('seo')) {
core.setCurrentPhase('seo');
}
} else if (seoAnalysis && !seoRecommendationsApplied && !seoMetadata) {
if (core.currentPhase !== 'seo' && canNavigateTo('seo')) {
if ((core.currentPhase === 'content' || core.currentPhase === 'seo') && canNavigateTo('seo')) {
core.setCurrentPhase('seo');
}
} else if (seoAnalysis && (seoRecommendationsApplied || seoMetadata)) {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { readLSString, readLSBool } from '../utils/persistence';
import { readLSString } from '../utils/persistence';
export interface PhaseBase {
id: string;
@@ -71,14 +71,7 @@ export const usePhaseNavigationCore = (
}
});
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
try {
if (typeof window !== 'undefined') {
return readLSBool(userSelectedKey, false);
}
} catch { /* noop */ }
return false;
});
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(false);
const lastClickAtRef = useRef<number>(0);
const oscillationGuardRef = useRef<OscillationState>({
@@ -90,10 +83,6 @@ export const usePhaseNavigationCore = (
try { localStorage.setItem(phaseKey, currentPhase); } catch { /* noop */ }
}, [currentPhase, phaseKey]);
useEffect(() => {
try { localStorage.setItem(userSelectedKey, String(userSelectedPhase)); } catch { /* noop */ }
}, [userSelectedPhase, userSelectedKey]);
const navigateToPhase = useCallback((phaseId: string, phases: PhaseBase[]) => {
const now = Date.now();
if (now - lastClickAtRef.current < 200) return;

View File

@@ -8,6 +8,8 @@ export interface WixStatus {
connected: boolean;
has_permissions: boolean;
site_info?: any;
error?: string;
reconnect_required?: boolean;
}
export interface WixPublishResult {
@@ -117,25 +119,14 @@ export function useWixPublish() {
setWixStatus({ connected: false, has_permissions: false });
return;
} catch (err: any) {
// Backend error (network, 500, etc.) — can't determine status
// Fall through to localStorage hint only if we have no other info
console.warn('[Wix] Backend connection check failed:', err?.message || err);
// Backend error (network, 500, etc.) — can't validate token
// Show disconnected rather than stale cached state — user should reconnect
console.warn('[Wix] Backend connection check failed (showing disconnected):', err?.message || err);
}
// 3. FALLBACK: localStorage is only a hint, never authoritative
const localConnected = localStorage.getItem(WIX_CONNECTED_KEY) === 'true';
const sessionConnected = sessionStorage.getItem(WIX_CONNECTED_KEY) === 'true';
const urlConnected = new URLSearchParams(window.location.search).get('wix_connected') === 'true';
if (localConnected || sessionConnected || urlConnected) {
// We have a hint that user was connected, but backend couldn't confirm
// Show as connected but with warning — user may need to reconnect
console.warn('[Wix] Showing cached connection state — backend validation failed. User may need to reconnect.');
setWixStatus({ connected: true, has_permissions: true });
return;
}
setWixStatus({ connected: false, has_permissions: false });
// 3. Network error fallback — never trust local cache over backend
clearStaleWixState();
setWixStatus({ connected: false, has_permissions: false, error: 'Unable to verify connection' });
} catch {
setWixStatus({ connected: false, has_permissions: false });
} finally {
@@ -192,10 +183,13 @@ export function useWixPublish() {
|| 'Blog Post';
let coverImageUrl: string | undefined;
let coverImageWarning: string | undefined;
if (metadata?.open_graph?.image) {
const img = metadata.open_graph.image;
if (typeof img === 'string' && (img.startsWith('http://') || img.startsWith('https://'))) {
coverImageUrl = img;
} else if (typeof img === 'string' && img.startsWith('data:image/')) {
coverImageWarning = 'Cover image is a data URI (base64) and will not be included in Wix publish. Wix requires a public image URL.';
}
}
@@ -260,7 +254,8 @@ export function useWixPublish() {
if (response.data.success) {
const url = response.data.url;
const warning = response.data.warning;
const apiWarning = response.data.warning;
const warnings = [apiWarning, coverImageWarning].filter(Boolean);
return {
success: true,
url,
@@ -268,7 +263,7 @@ export function useWixPublish() {
message: url
? `Blog post published to Wix! View it here: ${url}`
: 'Blog post published successfully to Wix!',
...(warning ? { warning } : {}),
...(warnings.length > 0 ? { warning: warnings.join(' ') } : {}),
};
}
return {

View File

@@ -188,6 +188,7 @@ export interface BlogSEOApplyRecommendationsRequest {
outline: BlogOutlineSection[];
research: Record<string, any>;
recommendations: BlogSEOActionableRecommendation[];
competitive_advantage?: string;
persona?: Record<string, any>;
tone?: string;
audience?: string;
@@ -463,7 +464,7 @@ export const blogWriterApi = {
return data;
},
async generateSection(payload: { section: BlogOutlineSection; keywords?: string[]; tone?: string; persona?: PersonaInfo; mode?: 'draft' | 'polished' }): Promise<BlogSectionResponse> {
async generateSection(payload: { section: BlogOutlineSection; keywords?: string[]; tone?: string; persona?: PersonaInfo; mode?: 'draft' | 'polished'; research?: BlogResearchResponse; competitive_advantage?: string }): Promise<BlogSectionResponse> {
const { data } = await apiClient.post("/api/blog/section/generate", payload);
return data;
},
@@ -471,7 +472,7 @@ export const blogWriterApi = {
// Removed old seoAnalyze - now using comprehensive SEO analysis through modal
async seoMetadata(payload: { content: string; title?: string; keywords?: string[] }): Promise<BlogSEOMetadataResponse> {
const { data } = await apiClient.post("/api/blog/seo/metadata", payload);
const { data } = await aiApiClient.post("/api/blog/seo/metadata", payload);
return data;
},

View File

@@ -5,6 +5,8 @@
* and avoid unnecessary API calls. Shared by both CopilotKit and manual flows.
*/
import { debug } from '../utils/debug';
class BlogWriterCacheService {
private readonly OUTLINE_CACHE_KEY = 'blog_outline';
private readonly TITLE_OPTIONS_CACHE_KEY = 'blog_title_options';
@@ -33,7 +35,7 @@ class BlogWriterCacheService {
// More sophisticated matching could compare research keywords if needed
const titleOptions = savedTitleOptions ? JSON.parse(savedTitleOptions) : undefined;
console.log(`Cache hit for outline (${parsedOutline.length} sections)`);
debug.log(`Cache hit for outline (${parsedOutline.length} sections)`);
return {
outline: parsedOutline,
title_options: titleOptions
@@ -55,7 +57,7 @@ class BlogWriterCacheService {
if (titleOptions) {
localStorage.setItem(this.TITLE_OPTIONS_CACHE_KEY, JSON.stringify(titleOptions));
}
console.log(`Cached outline (${outline.length} sections)`);
debug.log(`Cached outline (${outline.length} sections)`);
} catch (error) {
console.error('Error caching outline:', error);
}
@@ -102,11 +104,11 @@ class BlogWriterCacheService {
normalized[id] = (values[idx] || '') as string;
});
this.cacheContent(normalized, outlineIds);
console.log(`Cache hit for content after key normalization (${Object.keys(normalized).length} sections)`);
debug.log(`Cache hit for content after key normalization (${Object.keys(normalized).length} sections)`);
return normalized;
}
console.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);
debug.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);
return parsedSections;
} catch (error) {
console.error('Error retrieving cached content:', error);
@@ -124,7 +126,7 @@ class BlogWriterCacheService {
const cacheKey = this.generateContentCacheKey(outlineIds);
localStorage.setItem(cacheKey, JSON.stringify(sections));
console.log(`Cached content (${Object.keys(sections).length} sections)`);
debug.log(`Cached content (${Object.keys(sections).length} sections)`);
} catch (error) {
console.error('Error caching content:', error);
}

View File

@@ -5,7 +5,7 @@ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
export type TaskPriority = 'high' | 'medium' | 'low';
export type ActionType = 'navigate' | 'modal' | 'external';
export type WorkflowStatus = 'not_started' | 'in_progress' | 'completed' | 'paused' | 'stopped';
export type WorkflowGenerationMode = 'agent_committee' | 'llm_generation' | 'llm_pillar_backfill' | 'controlled_fallback';
export type WorkflowGenerationMode = 'agent_committee' | 'llm_generation' | 'llm_pillar_backfill' | 'controlled_fallback' | 'calendar_driven';
export interface WorkflowProvenanceSummary {
generationMode: WorkflowGenerationMode;

View File

@@ -5,6 +5,7 @@ export interface DiffSegment {
}
export interface SectionDiff {
id: string;
heading: string;
originalContent: string;
newContent: string;
@@ -105,7 +106,7 @@ export function getSectionDiffs(
const newContent = newSections[id] || '';
const segments = computeWordDiff(originalContent, newContent);
const changed = segments.some(s => s.added || s.removed);
return { heading, originalContent, newContent, segments, changed };
return { id, heading, originalContent, newContent, segments, changed };
});
let introductionDiff: DiffSegment[] | null = null;