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:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 40–60 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user