feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements

Issue #518 - Subscription not updating after checkout:
- Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef)
- Move checkout success polling from InitialRouteHandler into SubscriptionContext
- Remove redundant polling code from InitialRouteHandler
- Fix plan label: 'Free' instead of 'No Plan', proper capitalization
- Add plan refresh button in UserBadge
- Add 'View Costing Details' to UserBadge dropdown
- Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI
- Clean subscription=success URL param after verification

Blog Writer WYSIWYG Editor enhancements:
- Per-section preview toggle (view/edit icons)
- Enhanced hover-based toolbar
- Circular SVG progress stats bar with detailed tooltip
- Research tool chips in stats bar footer
- Per-section TTS with useTextToSpeech hook (browser native)
- Full blog preview modal with print/PDF support
- PlayAllTTSButton: sequential playback with progress bar
- OnThisPageNav: floating sidebar with scroll tracking
- Section data attributes for scroll anchoring

GSC Brainstorm Topics feature:
- Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations)
- Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation
- Frontend: gscBrainstorm.ts API client
- Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect)
- Frontend: useGSCBrainstorm hook (connect check + brainstorm call)
- Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs)
- Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay)
- Wire BrainstormButton into ManualResearchForm and ResearchAction
- Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
This commit is contained in:
ajaysi
2026-05-20 22:34:37 +05:30
parent 68190dedb3
commit 644e72d289
98 changed files with 16137 additions and 2501 deletions

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { Dialog, DialogContent, IconButton, Typography, Box, Tooltip } from '@mui/material';
import { Close as CloseIcon, Print as PrintIcon } from '@mui/icons-material';
interface BlogPreviewModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
introduction: string;
sections: Array<{
title: string;
content: string;
}>;
convertMarkdownToHTML: (md: string) => string;
}
export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
isOpen,
onClose,
title,
introduction,
sections,
convertMarkdownToHTML,
}) => {
const handlePrint = () => {
window.print();
};
return (
<>
<Dialog
open={isOpen}
onClose={onClose}
maxWidth="md"
fullWidth
fullScreen
sx={{
'& .MuiDialog-paper': {
bgcolor: '#fafbfc',
},
}}
>
{/* Header */}
<Box
sx={{
position: 'sticky',
top: 0,
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 1000,
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b' }}>
👁 Blog Preview
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Print or Save as PDF">
<IconButton onClick={handlePrint} sx={{ color: '#4f46e5' }}>
<PrintIcon />
</IconButton>
</Tooltip>
<Tooltip title="Return to Editing">
<IconButton onClick={onClose} sx={{ color: '#64748b' }}>
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Content */}
<DialogContent
sx={{
px: { xs: 2, md: 4 },
py: 4,
maxWidth: '800px',
mx: 'auto',
bgcolor: 'white',
borderRadius: 2,
my: 2,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
{/* Blog Title */}
<Typography
variant="h1"
sx={{
fontSize: { xs: '2rem', md: '2.5rem' },
fontWeight: 800,
color: '#1e293b',
mb: 3,
lineHeight: 1.2,
fontFamily: 'Georgia, serif',
}}
>
{title}
</Typography>
{/* Introduction */}
{introduction && introduction.trim() && (
<Box
sx={{
mb: 4,
pb: 4,
borderBottom: '2px solid #e5e7eb',
}}
>
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1.125rem',
lineHeight: 1.8,
color: '#475569',
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(introduction) }}
/>
</Box>
)}
{/* Sections */}
{sections.map((section, index) => (
<Box
key={section.title || index}
sx={{
mb: 4,
pb: 4,
borderBottom: index < sections.length - 1 ? '1px solid #f1f5f9' : 'none',
}}
>
{/* Section Title */}
<Typography
variant="h2"
sx={{
fontSize: { xs: '1.5rem', md: '1.75rem' },
fontWeight: 700,
color: '#1e293b',
mb: 2,
mt: 3,
fontFamily: 'Georgia, serif',
borderBottom: '1px solid #e5e7eb',
pb: 1,
}}
>
{section.title}
</Typography>
{/* Section Content */}
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1rem',
lineHeight: 1.8,
color: '#334155',
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(section.content) }}
/>
</Box>
))}
</DialogContent>
{/* Footer */}
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'white',
borderTop: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 1000,
}}
>
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
{sections.length} sections &bull; Preview Mode
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Typography variant="body2" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Press Ctrl+P to print
</Typography>
</Box>
</Box>
</Dialog>
{/* Print Styles */}
<style>{`
@media print {
body * {
visibility: hidden;
}
.MuiDialogContent-root, .MuiDialogContent-root * {
visibility: visible;
}
.MuiDialogContent-root {
position: absolute;
left: 0;
top: 0;
width: 100%;
box-shadow: none !important;
margin: 0 !important;
padding: 20px !important;
}
/* Hide UI elements */
.MuiDialog-paper > div:first-child,
.MuiDialog-paper > div:last-child {
display: none !important;
}
/* Optimize for print */
h1, h2, h3 {
page-break-after: avoid;
}
img {
max-width: 100% !important;
page-break-inside: avoid;
}
}
`}</style>
</>
);
};
export default BlogPreviewModal;

View File

@@ -1,5 +1,5 @@
import React, { useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
@@ -36,6 +36,8 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
const BlogWriter: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
// Add light theme class to body/html on mount, remove on unmount
React.useEffect(() => {
document.body.classList.add('blog-writer-page');
@@ -76,6 +78,7 @@ const BlogWriter: React.FC = () => {
flowAnalysisCompleted,
flowAnalysisResults,
sectionImages,
restoreAttempted,
setResearch,
setOutline,
setTitleOptions,
@@ -203,6 +206,21 @@ const BlogWriter: React.FC = () => {
// Store navigateToPhase in a ref for use in polling callbacks
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
// Normalize section keys to match outline IDs when updating from API responses
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
if (outline && outline.length > 0 && Object.keys(newSections).length > 0) {
const normalized: Record<string, string> = {};
const values = Object.values(newSections);
outline.forEach((s, idx) => {
const id = String(s.id);
normalized[id] = newSections[id] ?? values[idx] ?? '';
});
setSections(normalized);
} else {
setSections(newSections);
}
}, [outline, setSections]);
// Polling hooks - extracted to useBlogWriterPolling
const {
researchPolling,
@@ -216,7 +234,7 @@ const BlogWriter: React.FC = () => {
onResearchComplete: handleResearchComplete,
onOutlineComplete: handleOutlineComplete,
onOutlineError: handleOutlineError,
onSectionsUpdate: setSections,
onSectionsUpdate: handleSectionsUpdate,
onContentConfirmed: () => {
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
setContentConfirmed(true);
@@ -328,6 +346,14 @@ const BlogWriter: React.FC = () => {
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
setCurrentPhase]);
// Handle ?new=true query param from "New Blog" button in Asset Library
React.useEffect(() => {
if (searchParams.get('new') === 'true') {
handleNewBlog();
setSearchParams({}, { replace: true });
}
}, [searchParams, handleNewBlog, setSearchParams]);
const handleMyBlogs = useCallback(() => {
navigate('/asset-library?source_module=blog_writer&asset_type=text');
}, [navigate]);
@@ -532,6 +558,7 @@ const BlogWriter: React.FC = () => {
currentPhase={currentPhase}
navigateToPhase={navigateToPhase}
onResearchComplete={handleResearchComplete}
restoreAttempted={restoreAttempted}
/>
{research && (
@@ -572,6 +599,8 @@ const BlogWriter: React.FC = () => {
setShowOutlineModal(true);
}}
onContentGenerationStart={handleMediumGenerationStarted}
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</>
)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import BlogWriterLanding from '../BlogWriterLanding';
import ManualResearchForm from '../ManualResearchForm';
@@ -8,36 +9,61 @@ interface BlogWriterLandingSectionProps {
currentPhase: string;
navigateToPhase: (phase: string) => void;
onResearchComplete: (research: any) => void;
restoreAttempted?: boolean;
}
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
research,
copilotKitAvailable,
currentPhase,
navigateToPhase,
onResearchComplete,
restoreAttempted = false,
}) => {
// Only show landing/initial content when no research exists
// Phase navigation header is always visible, so this is just the initial content
if (!research) {
// Show research form only when user explicitly navigated to research phase (clicked "Start Research")
if (currentPhase === 'research') {
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
}
// Default: Always show landing page when no research exists
// This ensures landing page is shown on initial load
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
return (
<BlogWriterLanding
onStartWriting={() => {
navigateToPhase('research');
}}
/>
);
}
if (restoreAttempted) {
return (
<BlogWriterLanding
onStartWriting={() => {
navigateToPhase('research');
}}
/>
);
}
return (
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase to show the research form
navigateToPhase('research');
}}
/>
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="300px"
gap={2}
>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Restoring your work...
</Typography>
</Box>
);
}
// If research exists, don't show landing section (phase content will be shown instead)
return null;
};

View File

@@ -7,6 +7,7 @@ import OutlineCtaBanner from './OutlineCtaBanner';
import ManualResearchForm from '../ManualResearchForm';
import ManualOutlineButton from '../ManualOutlineButton';
import ManualContentButton from '../ManualContentButton';
import PublishContent from './PublishContent';
interface PhaseContentProps {
currentPhase: string;
@@ -40,6 +41,8 @@ interface PhaseContentProps {
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
buildFullMarkdown?: () => string;
convertMarkdownToHTML?: (md: string) => string;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
@@ -74,6 +77,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onResearchComplete,
onOutlineGenerationStart,
onContentGenerationStart,
buildFullMarkdown,
convertMarkdownToHTML,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -223,11 +228,14 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
</div>
)}
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
<div style={{ padding: '20px' }}>
<h3>Publish Your Blog</h3>
<p>Your blog is ready to publish!</p>
</div>
{currentPhase === 'publish' && buildFullMarkdown && convertMarkdownToHTML && (
<PublishContent
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
seoMetadata={seoMetadata}
seoAnalysis={seoAnalysis}
blogTitle={selectedTitle ?? undefined}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react';
import { apiClient } from '../../../api/client';
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../api/wordpress';
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
import WixConnectModal from './WixConnectModal';
import { useWixPublish } from '../../../hooks/useWixPublish';
const saveCompleteBlogAsset = async (
title: string,
content: string,
seoMetadata: BlogSEOMetadataResponse | null
) => {
try {
await apiClient.post('/api/blog/save-complete-asset', {
title,
content,
seo_title: seoMetadata?.seo_title,
meta_description: seoMetadata?.meta_description,
focus_keyword: seoMetadata?.focus_keyword,
tags: seoMetadata?.blog_tags || [],
categories: seoMetadata?.blog_categories || [],
});
} catch (error) {
console.error('Failed to save complete blog asset:', error);
}
};
interface PublishContentProps {
buildFullMarkdown: () => string;
convertMarkdownToHTML: (md: string) => string;
seoMetadata: BlogSEOMetadataResponse | null;
seoAnalysis?: any;
blogTitle?: string;
}
export const PublishContent: React.FC<PublishContentProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata,
blogTitle,
}) => {
const {
wixStatus,
checkingWix,
publishingWix,
publishToWix,
showWixConnectModal,
setShowWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
} = useWixPublish();
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
const [checkingWP, setCheckingWP] = useState(false);
const [publishing, setPublishing] = useState<string | null>(null);
const [publishResult, setPublishResult] = useState<{ platform: string; success: boolean; message: string; url?: string } | null>(null);
const [copyDone, setCopyDone] = useState(false);
useEffect(() => {
checkWPStatus();
}, []);
const checkWPStatus = async () => {
setCheckingWP(true);
try {
const status = await wordpressAPI.getStatus();
setWordpressSites(status.sites || []);
} catch {
setWordpressSites([]);
} finally {
setCheckingWP(false);
}
};
const publishToWordPress = async () => {
const md = buildFullMarkdown();
const html = convertMarkdownToHTML(md);
setPublishing('wordpress');
setPublishResult(null);
try {
if (!seoMetadata) {
setPublishResult({ platform: 'wordpress', success: false, message: 'Generate SEO metadata first before publishing.' });
return;
}
const activeSite = wordpressSites.find(s => s.is_active) || wordpressSites[0];
if (!activeSite) {
setPublishResult({ platform: 'wordpress', success: false, message: 'No WordPress sites connected. Go to Settings > Integrations to add one.' });
return;
}
const title = seoMetadata.seo_title || md.match(/^#\s+(.+)$/m)?.[1] || 'Blog Post';
const request: WordPressPublishRequest = {
site_id: activeSite.id,
title,
content: html,
excerpt: seoMetadata.meta_description || '',
status: 'publish',
meta_description: seoMetadata.meta_description || '',
tags: seoMetadata.blog_tags || [],
categories: seoMetadata.blog_categories || [],
};
const result = await wordpressAPI.publishContent(request);
if (result.success) {
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
} else {
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
}
} catch (err: any) {
setPublishResult({ platform: 'wordpress', success: false, message: err?.response?.data?.detail || err.message || 'Publish failed' });
} finally {
setPublishing(null);
}
};
const handlePublishToWix = async () => {
const md = buildFullMarkdown();
setPublishResult(null);
const result = await publishToWix(md, seoMetadata, blogTitle);
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.success) {
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
}
};
const handleWixClick = () => {
if (wixStatus?.connected) {
handlePublishToWix();
} else {
setShowWixConnectModal(true);
}
};
const handleCopyMarkdown = () => {
navigator.clipboard.writeText(buildFullMarkdown());
setCopyDone(true);
setTimeout(() => setCopyDone(false), 2000);
};
const handleCopyHTML = () => {
navigator.clipboard.writeText(convertMarkdownToHTML(buildFullMarkdown()));
setCopyDone(true);
setTimeout(() => setCopyDone(false), 2000);
};
const cardStyle: React.CSSProperties = {
background: '#ffffff',
borderRadius: 12,
border: '1px solid #e2e8f0',
padding: 24,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
};
const btnStyle: React.CSSProperties = {
padding: '10px 20px',
borderRadius: 8,
border: 'none',
fontWeight: 600,
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s',
};
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#0f172a' }}>Publish Your Blog</h2>
<p style={{ margin: '0 0 24px 0', color: '#64748b', fontSize: '0.9rem' }}>
Your blog is ready to publish. Choose a platform below.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* WordPress card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>WordPress</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
{checkingWP ? 'Checking connection...' : wordpressSites.length > 0 ? `${wordpressSites.length} site(s) connected` : 'No sites connected'}
</p>
</div>
<button
onClick={publishToWordPress}
disabled={publishing === 'wordpress' || wordpressSites.length === 0}
style={{
...btnStyle,
background: wordpressSites.length > 0 ? 'linear-gradient(135deg, #21759b, #1a6a8a)' : '#e2e8f0',
color: wordpressSites.length > 0 ? '#fff' : '#94a3b8',
cursor: wordpressSites.length > 0 && publishing !== 'wordpress' ? 'pointer' : 'not-allowed',
}}
>
{publishing === 'wordpress' ? 'Publishing...' : 'Publish to WordPress'}
</button>
</div>
{wordpressSites.length > 0 && wordpressSites[0] && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
Target: {wordpressSites[0].site_name} ({wordpressSites[0].site_url})
</div>
)}
</div>
{/* Wix card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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'}
</p>
</div>
<button
onClick={handleWixClick}
disabled={publishingWix}
style={{
...btnStyle,
background: wixStatus?.connected ? 'linear-gradient(135deg, #0a6eff, #0052cc)' : '#6366f1',
color: '#fff',
cursor: !publishingWix ? 'pointer' : 'not-allowed',
}}
>
{publishingWix ? 'Publishing...' : wixStatus?.connected ? 'Publish to Wix' : 'Connect Wix'}
</button>
</div>
{wixStatus?.connected && wixStatus.site_info && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
Site: {wixStatus.site_info.name || wixStatus.site_info.displayName}
</div>
)}
</div>
{/* Export card */}
<div style={cardStyle}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Export</h3>
<p style={{ margin: '4px 0 12px 0', fontSize: '0.85rem', color: '#64748b' }}>
Copy your blog content for use elsewhere
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleCopyMarkdown}
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
>
{copyDone ? 'Copied!' : 'Copy Markdown'}
</button>
<button
onClick={handleCopyHTML}
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
>
{copyDone ? 'Copied!' : 'Copy HTML'}
</button>
</div>
</div>
</div>
{/* Publish result */}
{publishResult && (
<div style={{
marginTop: 16,
padding: 16,
borderRadius: 8,
background: publishResult.success ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${publishResult.success ? '#86efac' : '#fecaca'}`,
color: publishResult.success ? '#166534' : '#991b1b',
}}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{publishResult.success ? '✅ Published!' : '❌ Publish failed'}
</div>
<div style={{ fontSize: '0.9rem' }}>{publishResult.message}</div>
{publishResult.url && (
<a href={publishResult.url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', marginTop: 4, display: 'inline-block' }}>
View published post
</a>
)}
</div>
)}
<WixConnectModal
isOpen={showWixConnectModal}
onClose={closeWixConnectModal}
onConnectionSuccess={handleWixConnectionSuccess}
/>
</div>
);
};
export default PublishContent;

View File

@@ -65,19 +65,34 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const params = new URLSearchParams(window.location.search);
if (params.get('wix_connected') === 'true') {
console.log('Wix connected via URL param in modal');
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {
onConnectionSuccess();
}
onClose();
// Clean URL
const clean = window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, clean || '/');
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
}
}, [isOpen, onClose, onConnectionSuccess]);
// Cross-tab: detect localStorage signal from OAuth in new tab
useEffect(() => {
if (!isOpen) return;
const handler = (e: StorageEvent) => {
if (e.key === 'wix_connected' && e.newValue === 'true') {
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {
onConnectionSuccess();
}
onClose();
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [isOpen, onClose, onConnectionSuccess]);
const handleConnectClick = async () => {
try {
setIsConnecting(true);
@@ -90,16 +105,10 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
const currentSearch = window.location.search;
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
// This ensures consistency between where OAuth starts and where callback happens
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const isUsingNgrok = window.location.origin.includes('localhost') ||
window.location.origin.includes('127.0.0.1') ||
window.location.origin === NGROK_ORIGIN;
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
// Build redirect URL with normalized origin
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
// Build redirect URL using the user's ACTUAL origin (where browser data lives).
// Wix OAuth callback URI uses NGROK_ORIGIN (for Wix to reach us), but after OAuth
// we must redirect back to the user's real origin so their localStorage data is available.
const redirectUrl = `${window.location.origin}${currentPath}${currentHash}${currentSearch}`;
try {
// Always override any existing redirect URL when connecting from Blog Writer
@@ -107,8 +116,6 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
redirectUrl,
currentOrigin: window.location.origin,
redirectOrigin,
isUsingNgrok
});
} catch (e) {
console.warn('[WixConnectModal] Failed to store redirect URL:', e);

View File

@@ -47,26 +47,50 @@ export const useBlogWriterPolling = ({
});
onSectionsUpdate(newSections);
// Cache the generated content (shared utility)
if (Object.keys(newSections).length > 0) {
const sectionIds = Object.keys(newSections);
blogWriterCache.cacheContent(newSections, sectionIds);
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens when user clicks "Next:Confirm and generate content"
if (onContentConfirmed) {
onContentConfirmed();
}
if (navigateToPhase) {
navigateToPhase('seo');
}
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens when user clicks "Next:Confirm and generate content"
if (onContentConfirmed) {
onContentConfirmed();
}
if (navigateToPhase) {
navigateToPhase('seo');
}
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
(async () => {
try {
const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi');
const totalWords = result.sections.reduce(
(sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length),
0
);
await saveBlogToAssetLibrary({
title: result.title || 'Untitled Blog',
blogType: 'medium',
wordCount: totalWords,
sectionCount: result.sections?.length,
model: result.model,
generationTimeMs: result.generation_time_ms,
});
} catch (assetError) {
console.error('[BlogWriter] Failed to save blog to asset library:', assetError);
}
})();
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
onError: (err: any) => {
console.error('Medium generation failed:', err);
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
} else if (errMsg.includes('no valid structured response')) {
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
}
}
});
// Rewrite polling hook (used for blog rewrite operations)

View File

@@ -168,7 +168,12 @@ export const usePhaseActionHandlers = ({
} catch (error) {
console.error('Content generation failed:', error);
setIsMediumGenerationStarting(false);
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
const errMsg = error instanceof Error ? error.message : 'Unknown error';
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
alert('Your API balance is insufficient. Please top up your WaveSpeed account or switch to a different provider (e.g., set GPT_PROVIDER=google in your environment).');
} else {
alert(`Content generation failed: ${errMsg}`);
}
}
} else {
// For longer blogs, just confirm outline - user will use manual button

View File

@@ -233,13 +233,18 @@ 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) {
debug.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
console.log('[SEOManager] Restored cached SEO analysis', { cacheKey, 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);

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useRef } from 'react';
import { useGSCBrainstorm } from '../../hooks/useGSCBrainstorm';
import { GSCBrainstormModal } from './GSCBrainstormModal';
interface BrainstormButtonProps {
keywords: string;
onKeywordsChange: (val: string) => void;
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
disabled?: boolean;
}
export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
keywords,
onKeywordsChange,
onBrainstormResult,
disabled = false,
}) => {
const [showModal, setShowModal] = useState(false);
const [showConnectOverlay, setShowConnectOverlay] = useState(false);
const pendingBrainstormRef = useRef(false);
const {
gscConnected,
isConnecting,
connectError,
isBrainstorming,
brainstormError,
contentOpportunities,
keywordGaps,
aiRecommendations,
summary,
connectGSC,
brainstorm,
reset,
} = useGSCBrainstorm();
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
const isVisible = wordCount >= 3;
// Auto-trigger brainstorm after GSC connection succeeds
useEffect(() => {
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
pendingBrainstormRef.current = false;
brainstorm(keywords).then((result) => {
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
});
}
}, [gscConnected, isConnecting]);
const handleClick = async () => {
if (!gscConnected) {
setShowConnectOverlay(true);
return;
}
setShowModal(true);
const result = await brainstorm(keywords);
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
};
const handleSelectSuggestion = (suggestion: string) => {
onKeywordsChange(suggestion);
setShowModal(false);
reset();
};
const handleConnectGSC = async () => {
pendingBrainstormRef.current = true;
await connectGSC();
};
const handleConnectSuccess = async () => {
setShowConnectOverlay(false);
setShowModal(true);
const result = await brainstorm(keywords);
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
};
const handleConnectCancel = () => {
setShowConnectOverlay(false);
pendingBrainstormRef.current = false;
};
if (!isVisible) return null;
return (
<>
<button
onClick={handleClick}
disabled={disabled || isBrainstorming}
title={
wordCount < 3
? 'Enter at least 3 words to enable brainstorming'
: 'Brainstorm topics using your Google Search Console data'
}
style={{
padding: '12px 20px',
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: disabled || isBrainstorming ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.7 : 1,
display: 'flex',
alignItems: 'center',
gap: '6px',
whiteSpace: 'nowrap',
transition: 'background-color 0.15s',
}}
>
{isBrainstorming ? (
<>
<span
style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid #fff',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'brainstormSpin 0.8s linear infinite',
}}
/>
<style>{`@keyframes brainstormSpin { to { transform: rotate(360deg); } }`}</style>
Analyzing...
</>
) : (
'Brainstorm Topics'
)}
</button>
<GSCBrainstormModal
open={showModal}
onClose={() => {
setShowModal(false);
reset();
}}
contentOpportunities={contentOpportunities}
keywordGaps={keywordGaps}
aiRecommendations={aiRecommendations}
summary={summary}
error={brainstormError}
isBrainstorming={isBrainstorming}
onSelectSuggestion={handleSelectSuggestion}
/>
{showConnectOverlay && (
<GSConnectOverlay
isConnecting={isConnecting}
connectError={connectError}
gscConnected={gscConnected}
onConnect={handleConnectGSC}
onSuccess={handleConnectSuccess}
onCancel={handleConnectCancel}
/>
)}
</>
);
};
/* ------------------------------------------------------------------ */
/* GSC Connection Overlay */
/* ------------------------------------------------------------------ */
const GSConnectOverlay: React.FC<{
isConnecting: boolean;
connectError: string | null;
gscConnected: boolean;
onConnect: () => void;
onSuccess: () => void;
onCancel: () => void;
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
// If connection just succeeded, auto-proceed
if (gscConnected && !isConnecting) {
onSuccess();
return null;
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
}}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '12px',
padding: '32px',
maxWidth: '440px',
textAlign: 'center',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
<h3 style={{ margin: '0 0 8px', fontSize: '18px', color: '#333' }}>
Connect Google Search Console
</h3>
<p style={{ margin: '0 0 20px', fontSize: '14px', color: '#666', lineHeight: 1.5 }}>
Brainstorm Topics uses your Google Search Console data to suggest blog topics
based on what your audience is actually searching for.
</p>
{connectError && (
<p style={{ color: '#d32f2f', fontSize: '13px', margin: '0 0 16px' }}>{connectError}</p>
)}
{isConnecting ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
<div
style={{
width: '20px',
height: '20px',
border: '2px solid #e0e0e0',
borderTopColor: '#4caf50',
borderRadius: '50%',
animation: 'gscSpin 0.8s linear infinite',
}}
/>
<style>{`@keyframes gscSpin { to { transform: rotate(360deg); } }`}</style>
<span style={{ fontSize: '14px', color: '#666' }}>Opening Google sign-in...</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={onConnect}
style={{
padding: '12px 24px',
backgroundColor: '#4caf50',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Connect Google Search Console
</button>
<button
onClick={onCancel}
style={{
padding: '8px 24px',
backgroundColor: 'transparent',
color: '#888',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '13px',
cursor: 'pointer',
}}
>
Cancel
</button>
<p style={{ fontSize: '12px', color: '#999', margin: '4px 0 0' }}>
You'll be redirected to Google to authorize access. Your data stays private.
</p>
</div>
)}
</div>
</div>
);
};
export default BrainstormButton;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
import React from 'react';
import {
ContentOpportunity,
KeywordGap,
AIRecommendations,
BrainstormSummary,
} from '../../api/gscBrainstorm';
interface GSCBrainstormModalProps {
open: boolean;
onClose: () => void;
contentOpportunities: ContentOpportunity[];
keywordGaps: KeywordGap[];
aiRecommendations: AIRecommendations | null;
summary: BrainstormSummary | null;
error: string | null;
isBrainstorming: boolean;
onSelectSuggestion: (keyword: string) => void;
}
const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const;
type TabKey = typeof tabLabels[number];
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
open,
onClose,
contentOpportunities,
keywordGaps,
aiRecommendations,
summary,
error,
isBrainstorming,
onSelectSuggestion,
}) => {
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
if (!open) return null;
const hasNoData =
!isBrainstorming &&
!error &&
contentOpportunities.length === 0 &&
keywordGaps.length === 0 &&
!aiRecommendations;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '12px',
width: '90%',
maxWidth: '720px',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 24px',
borderBottom: '1px solid #e0e0e0',
}}
>
<div>
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
Brainstorm Topics with GSC Data
</h3>
{summary && (
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
{summary.site_url} &middot; {summary.date_range?.start} to {summary.date_range?.end} &middot;{' '}
{summary.total_keywords_analyzed} keywords analyzed
</p>
)}
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: '#888',
padding: '4px 8px',
}}
aria-label="Close"
>
x
</button>
</div>
{/* Summary metrics bar */}
{summary && summary.total_keywords_analyzed > 0 && (
<div
style={{
display: 'flex',
gap: '16px',
padding: '12px 24px',
backgroundColor: '#f0f7ff',
borderBottom: '1px solid #e0e0e0',
fontSize: '13px',
flexWrap: 'wrap',
}}
>
<span>
<strong>{summary.total_impressions?.toLocaleString()}</strong> impressions
</span>
<span>
<strong>{summary.total_clicks?.toLocaleString()}</strong> clicks
</span>
<span>
<strong>{summary.avg_ctr}%</strong> avg CTR
</span>
<span>
<strong>{summary.avg_position}</strong> avg position
</span>
</div>
)}
{/* Loading */}
{isBrainstorming && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #e0e0e0',
borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px',
}}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<p style={{ color: '#666', margin: 0 }}>
Analyzing your GSC data and generating topic suggestions...
</p>
</div>
)}
{/* Error */}
{error && !isBrainstorming && (
<div
style={{
padding: '24px',
textAlign: 'center',
}}
>
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
{error}
</p>
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>
Make sure your Google Search Console is connected and has data for the last 30 days.
</p>
</div>
)}
{/* No data */}
{hasNoData && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<p style={{ color: '#888', margin: 0 }}>
No brainstorming data available. Try different keywords or check your GSC connection.
</p>
</div>
)}
{/* Results */}
{!isBrainstorming && !error && !hasNoData && (
<>
{/* Tabs */}
<div
style={{
display: 'flex',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#fafafa',
}}
>
{tabLabels.map((tab) => {
const count =
tab === 'Opportunities'
? contentOpportunities.length
: tab === 'Keyword Gaps'
? keywordGaps.length
: aiRecommendations
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
(aiRecommendations.content_strategy?.length ?? 0) +
(aiRecommendations.long_term_strategy?.length ?? 0)
: 0;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
background: activeTab === tab ? '#fff' : 'transparent',
color: activeTab === tab ? '#1976d2' : '#666',
fontWeight: activeTab === tab ? 600 : 400,
cursor: 'pointer',
fontSize: '13px',
}}
>
{tab}
{count > 0 && (
<span
style={{
marginLeft: '6px',
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
color: '#fff',
borderRadius: '10px',
padding: '1px 7px',
fontSize: '11px',
}}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'Opportunities' && (
<OpportunitiesTab
opportunities={contentOpportunities}
onSelect={onSelectSuggestion}
/>
)}
{activeTab === 'Keyword Gaps' && (
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
)}
{activeTab === 'AI Recommendations' && (
<AIRecommendationsTab
recommendations={aiRecommendations}
onSelect={onSelectSuggestion}
/>
)}
</div>
</>
)}
{/* Footer */}
<div
style={{
padding: '12px 24px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<button
onClick={onClose}
style={{
padding: '8px 20px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Close
</button>
</div>
</div>
</div>
);
};
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
const OpportunitiesTab: React.FC<{
opportunities: ContentOpportunity[];
onSelect: (keyword: string) => void;
}> = ({ opportunities, onSelect }) => {
if (opportunities.length === 0) {
return <EmptyMessage message="No content opportunities found for this period." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{opportunities.map((opp, i) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'background-color 0.15s',
}}
onClick={() => onSelect(opp.keyword)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
{opp.keyword}
</span>
<div style={{ display: 'flex', gap: '6px' }}>
<Badge
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
/>
<Badge
label={opp.priority}
color={opp.priority === 'High' ? '#d32f2f' : '#666'}
/>
</div>
</div>
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
{opp.opportunity}
</p>
<div style={{ fontSize: '12px', color: '#999' }}>
{opp.impressions.toLocaleString()} impressions &middot; Position {opp.current_position}
</div>
</div>
))}
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
Click any keyword to use it as your research topic.
</p>
</div>
);
};
const GapsTab: React.FC<{
gaps: KeywordGap[];
onSelect: (keyword: string) => void;
}> = ({ gaps, onSelect }) => {
if (gaps.length === 0) {
return (
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{gaps.map((gap, i) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background-color 0.15s',
}}
onClick={() => onSelect(gap.keyword)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
>
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
<div style={{ fontSize: '12px', color: '#999' }}>
Position {gap.position} &middot; {gap.impressions.toLocaleString()} impressions
</div>
</div>
))}
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
These keywords rank between positions 4-20. Writing targeted content could push them to page 1.
</p>
</div>
);
};
const AIRecommendationsTab: React.FC<{
recommendations: AIRecommendations | null;
onSelect: (keyword: string) => void;
}> = ({ recommendations, onSelect }) => {
if (!recommendations) {
return <EmptyMessage message="AI recommendations are not available right now." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<RecommendationSection
title="Immediate Opportunities (0-30 days)"
items={recommendations.immediate_opportunities}
onSelect={onSelect}
color="#1565c0"
/>
<RecommendationSection
title="Content Strategy (1-3 months)"
items={recommendations.content_strategy}
onSelect={onSelect}
color="#2e7d32"
/>
<RecommendationSection
title="Long-Term Vision (3-12 months)"
items={recommendations.long_term_strategy}
onSelect={onSelect}
color="#6a1b9a"
/>
</div>
);
};
const RecommendationSection: React.FC<{
title: string;
items: string[];
onSelect: (keyword: string) => void;
color: string;
}> = ({ title, items, onSelect, color }) => {
if (!items || items.length === 0) return null;
return (
<div>
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
{items.map((item, i) => (
<li
key={i}
style={{
fontSize: '13px',
color: '#444',
marginBottom: '4px',
cursor: 'pointer',
}}
onClick={() => {
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
if (short) onSelect(short);
}}
>
{item}
</li>
))}
</ul>
</div>
);
};
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '4px',
color: '#fff',
backgroundColor: color,
}}
>
{label}
</span>
);
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p style={{ color: '#888', margin: 0 }}>{message}</p>
</div>
);
export default GSCBrainstormModal;

View File

@@ -1,15 +1,16 @@
import React, { useRef } from 'react';
import React, { useState } from 'react';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { BrainstormButton } from './BrainstormButton';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
}
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
const {
startResearch,
@@ -23,15 +24,15 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
} = useResearchSubmit({ onResearchComplete });
const handleSubmit = async () => {
const keywords = (keywordsRef.current?.value || '').trim();
if (!keywords) {
const trimmed = keywords.trim();
if (!trimmed) {
alert('Please enter keywords or a topic for research.');
return;
}
try {
await startResearch(keywords, blogLengthRef.current?.value || '1000');
} catch (error) {
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
await startResearch(trimmed, blogLength);
} catch (err) {
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
@@ -49,7 +50,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
disabled={isSubmitting}
style={{
width: '100%',
@@ -67,8 +69,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
value={blogLength}
onChange={(e) => setBlogLength(e.target.value)}
disabled={isSubmitting}
style={{
width: '100%',
@@ -88,6 +90,11 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<BrainstormButton
keywords={keywords}
onKeywordsChange={setKeywords}
disabled={isSubmitting}
/>
<button
onClick={handleSubmit}
disabled={isSubmitting}
@@ -122,5 +129,4 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
);
};
export default ManualResearchForm;
export default ManualResearchForm;

View File

@@ -21,6 +21,12 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
const getUserFriendlyMessage = (message: string): string => {
// Map technical backend messages to user-friendly ones
if (message.includes('insufficient_balance') || message.includes('balance_not_enough') || (message.includes('403') && message.includes('balance'))) {
return '💳 Your API balance is insufficient. Please top up your account or switch providers in your settings.';
}
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
}
if (message.includes('Starting outline generation')) {
return '🧩 Starting to create your blog outline...';
}

View File

@@ -0,0 +1,245 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { IconButton, Tooltip, Box, Typography, LinearProgress } from '@mui/material';
import { PlayArrow, Pause, Stop, VolumeUp } from '@mui/icons-material';
import { useTextToSpeech } from '../../hooks/useTextToSpeech';
interface PlayAllTTSButtonProps {
title: string;
introduction: string;
sections: Array<{
title: string;
content: string;
}>;
disabled?: boolean;
}
export const PlayAllTTSButton: React.FC<PlayAllTTSButtonProps> = ({
title,
introduction,
sections,
disabled = false,
}) => {
const { speak, stop, isSpeaking, isSupported, isPaused, pause, resume } = useTextToSpeech();
const [isPlayingAll, setIsPlayingAll] = useState(false);
const [currentSectionIndex, setCurrentSectionIndex] = useState(-1);
const [isPausedAll, setIsPausedAll] = useState(false);
const currentIndexRef = useRef(0);
const isPlayingRef = useRef(false);
const isWaitingForNextRef = useRef(false);
// Strip markdown for cleaner TTS
const stripMarkdown = (md: string) => {
return md
.replace(/[#*_~`]/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\n{2,}/g, '\n')
.trim();
};
// Build all content as array of sections
const allContent = React.useMemo(() => {
const content: Array<{ label: string; text: string }> = [];
if (title) {
content.push({ label: 'Title', text: stripMarkdown(title) });
}
if (introduction && introduction.trim()) {
content.push({ label: 'Introduction', text: stripMarkdown(introduction) });
}
sections.forEach((section, index) => {
if (section.content && section.content.trim()) {
content.push({
label: section.title || `Section ${index + 1}`,
text: stripMarkdown(section.content)
});
}
});
return content;
}, [title, introduction, sections]);
const totalSections = allContent.length;
// Play next section
const playNext = useCallback(() => {
if (currentIndexRef.current >= totalSections || !isPlayingRef.current) {
// All done or stopped
setIsPlayingAll(false);
setCurrentSectionIndex(-1);
currentIndexRef.current = 0;
isPlayingRef.current = false;
isWaitingForNextRef.current = false;
return;
}
const current = allContent[currentIndexRef.current];
if (!current || !current.text) {
// Skip empty sections
currentIndexRef.current += 1;
playNext();
return;
}
setCurrentSectionIndex(currentIndexRef.current);
isWaitingForNextRef.current = true;
speak(current.text, { rate: 1 });
}, [allContent, totalSections, speak]);
// Monitor speech completion
useEffect(() => {
if (!isPlayingAll || isPausedAll) return;
// If we were waiting for speech to end and now isSpeaking is false, play next
if (isWaitingForNextRef.current && !isSpeaking) {
isWaitingForNextRef.current = false;
currentIndexRef.current += 1;
// Small delay before next section
const timer = setTimeout(() => {
if (isPlayingRef.current) {
playNext();
}
}, 300);
return () => clearTimeout(timer);
}
}, [isSpeaking, isPlayingAll, isPausedAll, playNext]);
// Start playing all
const handlePlayAll = useCallback(() => {
if (totalSections === 0) return;
stop();
currentIndexRef.current = 0;
isPlayingRef.current = true;
setIsPlayingAll(true);
setIsPausedAll(false);
isWaitingForNextRef.current = false;
playNext();
}, [totalSections, stop, playNext]);
// Stop playing
const handleStop = useCallback(() => {
stop();
isPlayingRef.current = false;
setIsPlayingAll(false);
setCurrentSectionIndex(-1);
currentIndexRef.current = 0;
setIsPausedAll(false);
isWaitingForNextRef.current = false;
}, [stop]);
// Pause/Resume
const handlePauseResume = useCallback(() => {
if (isPaused) {
resume();
setIsPausedAll(false);
} else {
pause();
setIsPausedAll(true);
}
}, [isPaused, pause, resume]);
// Cleanup on unmount
useEffect(() => {
return () => {
isPlayingRef.current = false;
};
}, []);
if (!isSupported || totalSections === 0) {
return null;
}
const progress = totalSections > 0 && currentSectionIndex >= 0
? ((currentSectionIndex + 1) / totalSections) * 100
: 0;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Play All Button */}
{!isPlayingAll ? (
<Tooltip title="Read entire blog aloud">
<IconButton
onClick={handlePlayAll}
disabled={disabled}
size="small"
sx={{
color: '#4f46e5',
bgcolor: 'rgba(79, 70, 229, 0.1)',
'&:hover': {
bgcolor: 'rgba(79, 70, 229, 0.2)',
},
}}
>
<VolumeUp sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
) : (
<>
{/* Pause/Resume Button */}
<Tooltip title={isPausedAll ? 'Resume' : 'Pause'}>
<IconButton
onClick={handlePauseResume}
size="small"
sx={{
color: '#d97706',
bgcolor: 'rgba(217, 119, 6, 0.1)',
'&:hover': {
bgcolor: 'rgba(217, 119, 6, 0.2)',
},
}}
>
{isPausedAll ? <PlayArrow sx={{ fontSize: 18 }} /> : <Pause sx={{ fontSize: 18 }} />}
</IconButton>
</Tooltip>
{/* Stop Button */}
<Tooltip title="Stop reading">
<IconButton
onClick={handleStop}
size="small"
sx={{
color: '#ef4444',
bgcolor: 'rgba(239, 68, 68, 0.1)',
'&:hover': {
bgcolor: 'rgba(239, 68, 68, 0.2)',
},
}}
>
<Stop sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{/* Progress Indicator */}
<Box sx={{ flex: 1, minWidth: 100, maxWidth: 150 }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b', display: 'block' }}>
{currentSectionIndex >= 0 ? allContent[currentSectionIndex]?.label : ''}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: isPausedAll ? '#d97706' : '#4f46e5',
borderRadius: 2,
},
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
{currentSectionIndex + 1} of {totalSections}
</Typography>
</Box>
</>
)}
</Box>
);
};
export default PlayAllTTSButton;

View File

@@ -3,8 +3,8 @@ import { useCopilotAction } from '@copilotkit/react-core';
import { BlogSEOMetadataResponse } from '../../services/blogWriterApi';
import { apiClient } from '../../api/client';
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../api/wordpress';
import { validateAndRefreshWixTokens } from '../../utils/wixTokenUtils';
import WixConnectModal from './BlogWriterUtils/WixConnectModal';
import { useWixPublish } from '../../hooks/useWixPublish';
interface PublisherProps {
buildFullMarkdown: () => string;
@@ -34,57 +34,31 @@ const saveCompleteBlogAsset = async (
const useCopilotActionTyped = useCopilotAction as any;
interface WixConnectionStatus {
connected: boolean;
has_permissions: boolean;
site_info?: any;
permissions?: any;
error?: string;
}
export const Publisher: React.FC<PublisherProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata
}) => {
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
const {
publishToWix,
showWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
} = useWixPublish();
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
const [checkingWordPressStatus, setCheckingWordPressStatus] = useState(false);
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
const [pendingWixPublish, setPendingWixPublish] = useState<(() => Promise<any>) | null>(null);
// Check platform connection statuses on component mount
useEffect(() => {
checkWixConnectionStatus();
checkWordPressConnectionStatus();
}, []);
const checkWixConnectionStatus = async () => {
setCheckingWixStatus(true);
try {
const response = await apiClient.get('/api/wix/connection/status');
setWixConnectionStatus(response.data);
} catch (error) {
console.error('Failed to check Wix connection status:', error);
setWixConnectionStatus({
connected: false,
has_permissions: false,
error: 'Failed to check connection status'
});
} finally {
setCheckingWixStatus(false);
}
};
const checkWordPressConnectionStatus = async () => {
setCheckingWordPressStatus(true);
try {
const status = await wordpressAPI.getStatus();
setWordpressSites(status.sites || []);
} catch (error: any) {
// getStatus now handles 404 gracefully, so we should rarely hit this
// Only log non-404 errors
if (error?.response?.status !== 404) {
console.error('Failed to check WordPress connection status:', error);
}
@@ -94,132 +68,6 @@ export const Publisher: React.FC<PublisherProps> = ({
}
};
// Helper function to publish to Wix
const publishToWix = async (md: string, metadata: BlogSEOMetadataResponse | null, accessToken?: string): Promise<any> => {
// Get access token if not provided
if (!accessToken) {
const tokenResult = await validateAndRefreshWixTokens();
if (!tokenResult.accessToken) {
return {
success: false,
message: 'Wix tokens not available. Please connect your Wix account.',
action_required: 'connect_wix'
};
}
accessToken = tokenResult.accessToken;
}
// Extract title from SEO metadata or markdown
const title = metadata?.seo_title || (() => {
const titleMatch = md.match(/^#\s+(.+)$/m);
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
})();
// Extract cover image URL, skip if base64 (Wix needs HTTP URL)
let coverImageUrl: string | undefined = undefined;
if (metadata?.open_graph?.image) {
const imageUrl = metadata.open_graph.image;
// Skip base64 images - Wix import_image needs HTTP/HTTPS URL
if (typeof imageUrl === 'string' && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
coverImageUrl = imageUrl;
} else {
console.warn('Skipping cover image - Wix requires HTTP/HTTPS URL, received:', imageUrl?.substring(0, 50));
}
}
try {
// Publish using same endpoint as WixTestPage
// Backend will lookup/create category and tag IDs from names if needed
const response = await apiClient.post('/api/wix/test/publish/real', {
title: title,
content: md, // Use markdown, backend converts it
cover_image_url: coverImageUrl,
// Pass category/tag names - backend will lookup existing or create new ones
category_names: metadata?.blog_categories || [],
tag_names: metadata?.blog_tags || [],
publish: true,
access_token: accessToken,
member_id: undefined, // Let backend derive from token
seo_metadata: metadata ? {
seo_title: metadata.seo_title,
meta_description: metadata.meta_description,
focus_keyword: metadata.focus_keyword,
blog_tags: metadata.blog_tags || [], // Used for SEO keywords
social_hashtags: metadata.social_hashtags || [],
open_graph: metadata.open_graph || {},
twitter_card: metadata.twitter_card || {},
canonical_url: metadata.canonical_url
} : undefined
});
if (response.data.success) {
return {
success: true,
url: response.data.url,
post_id: response.data.post_id,
message: 'Blog post published successfully to Wix!'
};
} else {
return {
success: false,
message: response.data.error || 'Failed to publish to Wix'
};
}
} catch (error: any) {
// If auth error, token may be invalid - try refreshing or reconnect
if (error.response?.status === 401 || error.response?.status === 403) {
// Try to refresh one more time
const tokenResult = await validateAndRefreshWixTokens();
if (tokenResult.needsReconnect) {
const publishFunction = async () => {
return await publishToWix(md, metadata);
};
setPendingWixPublish(() => publishFunction);
setShowWixConnectModal(true);
return {
success: false,
message: 'Wix tokens expired. Please reconnect your Wix account.',
action_required: 'reconnect_wix'
};
}
// If refresh worked, retry once
if (tokenResult.accessToken) {
return await publishToWix(md, metadata, tokenResult.accessToken);
}
}
return {
success: false,
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
};
}
};
// Handle Wix connection success - retry publish
const handleWixConnectionSuccess = async () => {
if (pendingWixPublish) {
const publishFn = pendingWixPublish;
setPendingWixPublish(null);
// Small delay to ensure tokens are saved in sessionStorage
setTimeout(async () => {
try {
// Retry the publish - this will be executed and return result
// Note: The result won't show in CopilotKit UI since we're outside the action handler
// But the publish will succeed and user will see their blog on Wix
const result = await publishFn();
console.log('Wix publish after connection:', result);
// Optionally show a success notification
if (result.success) {
// Publish succeeded - user's blog is now on Wix
console.log('Blog published to Wix successfully after connection');
}
} catch (error) {
console.error('Error retrying publish after connection:', error);
}
}, 500);
}
};
// Enhanced publish action with Wix support
useCopilotActionTyped({
name: 'publishToPlatform',
description: 'Publish the blog to Wix or WordPress',
@@ -232,25 +80,7 @@ export const Publisher: React.FC<PublisherProps> = ({
const html = convertMarkdownToHTML(md);
if (platform === 'wix') {
// Proactively validate and refresh tokens
const tokenResult = await validateAndRefreshWixTokens();
if (tokenResult.needsReconnect || !tokenResult.accessToken) {
// Store the publish function to retry after connection
const publishFunction = async () => {
return await publishToWix(md, seoMetadata);
};
setPendingWixPublish(() => publishFunction);
setShowWixConnectModal(true);
return {
success: false,
message: 'Wix account not connected. Please connect your Wix account to publish.',
action_required: 'connect_wix'
};
}
// We have a valid access token, proceed with publishing
const wixResult = await publishToWix(md, seoMetadata, tokenResult.accessToken);
const wixResult = await publishToWix(md, seoMetadata);
if (wixResult.success) {
saveCompleteBlogAsset(
seoMetadata?.seo_title || 'Blog Post',
@@ -260,7 +90,6 @@ export const Publisher: React.FC<PublisherProps> = ({
}
return wixResult;
} else if (platform === 'wordpress') {
// WordPress publishing
if (!seoMetadata) {
return {
success: false,
@@ -268,7 +97,6 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Check if user has connected WordPress sites
if (wordpressSites.length === 0) {
return {
success: false,
@@ -277,7 +105,6 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Find first active site, or use first site if none are active
const activeSite = wordpressSites.find(site => site.is_active) || wordpressSites[0];
if (!activeSite) {
return {
@@ -287,16 +114,13 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Extract title from SEO metadata or markdown
const title = seoMetadata.seo_title || (() => {
const titleMatch = md.match(/^#\s+(.+)$/m);
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
})();
// Extract excerpt from SEO metadata
const excerpt = seoMetadata.meta_description || '';
// Build WordPress publish request
const publishRequest: WordPressPublishRequest = {
site_id: activeSite.id,
title: title,
@@ -395,10 +219,7 @@ export const Publisher: React.FC<PublisherProps> = ({
<>
<WixConnectModal
isOpen={showWixConnectModal}
onClose={() => {
setShowWixConnectModal(false);
setPendingWixPublish(null);
}}
onClose={closeWixConnectModal}
onConnectionSuccess={handleWixConnectionSuccess}
/>
</>

View File

@@ -1,8 +1,9 @@
import React, { useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { BrainstormButton } from './BrainstormButton';
const useCopilotActionTyped = useCopilotAction as any;
@@ -12,8 +13,8 @@ interface ResearchActionProps {
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const [copilotKeywords, setCopilotKeywords] = useState('');
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
const hasNavigatedRef = useRef<boolean>(false);
const {
@@ -111,7 +112,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
value={copilotKeywords}
onChange={(e) => setCopilotKeywords(e.target.value)}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
/>
@@ -121,8 +123,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
value={copilotBlogLength}
onChange={(e) => setCopilotBlogLength(e.target.value)}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
>
@@ -134,17 +136,22 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<BrainstormButton
keywords={copilotKeywords}
onKeywordsChange={setCopilotKeywords}
disabled={isSubmitting}
/>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
await startResearch(keywords, blogLength);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
onClick={async () => {
const kw = copilotKeywords.trim();
const bl = copilotBlogLength;
if (!kw) return;
try {
await startResearch(kw, bl);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
disabled={isSubmitting}
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
>

View File

@@ -166,6 +166,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const [contentHash, setContentHash] = useState<string>('');
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [fromCache, setFromCache] = useState(false);
// Debug logging only in development and when modal state changes meaningfully
useEffect(() => {
@@ -213,6 +214,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
// Validate cached data has required fields
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
setFromCache(true);
setAnalysisResult(parsed);
setIsAnalyzing(false);
setProgress(100);
@@ -322,6 +324,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
generated_at: new Date().toISOString()
};
setFromCache(false);
setAnalysisResult(convertedResult);
// Save to cache - use the same cacheKey that was used for checking
@@ -482,6 +485,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
SEO Analysis Results
</Typography>
{fromCache && analysisResult?.generated_at && (
<Chip
label={`Cached: ${new Date(analysisResult.generated_at).toLocaleString()}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
@@ -493,7 +504,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
runSEOAnalysis(true);
}}
>
Refresh
{fromCache ? 'Re-Run Analysis' : 'Run Analysis'}
</Button>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />

View File

@@ -212,14 +212,19 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const result = response.data;
console.log('✅ SEO metadata generation response:', result);
// Check if the response indicates a subscription error (even if HTTP status is 200)
// Check if the response indicates a subscription/usage error (even if HTTP status is 200)
if (!result.success && result.error) {
const errorMessage = result.error;
// Check if error message indicates subscription limit (429/402)
if (errorMessage.includes('Token limit') ||
const errorMessage = (result.error || '').toLowerCase();
// Check if error message indicates subscription/balance limit
if (errorMessage.includes('token limit') ||
errorMessage.includes('balance') ||
errorMessage.includes('insufficient') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription')) {
errorMessage.includes('subscription') ||
errorMessage.includes('403') ||
errorMessage.includes('429') ||
errorMessage.includes('quota')) {
console.log('SEOMetadataModal: Detected subscription error in response data', {
error: errorMessage,
data: result
@@ -297,13 +302,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} catch (err: any) {
console.error('❌ SEO metadata generation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
// Check if this is a subscription error (429/402/403) or balance/limit issue
const status = err?.response?.status;
const errorMessage = err?.message || err?.response?.data?.error || '';
const rawError = err?.response?.data?.error || err?.response?.data?.message || '';
const errorMessage = err?.message || rawError || '';
const fullMessage = (errorMessage + ' ' + rawError + ' ' + JSON.stringify(err?.response?.data || {})).toLowerCase();
// Check HTTP status code first
if (status === 429 || status === 402) {
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
// Check HTTP status code for subscription/balance errors
if (status === 429 || status === 402 || status === 403) {
console.log('SEOMetadataModal: Detected usage/subscription error (HTTP status)', {
status,
data: err?.response?.data
});
@@ -317,18 +324,21 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription') ||
errorMessage.includes('429')) {
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
errorMessage,
// Check error message for balance/usage/subscription-related errors
if (fullMessage.includes('balance') ||
fullMessage.includes('insufficient') ||
fullMessage.includes('limit would be exceeded') ||
fullMessage.includes('usage limit') ||
fullMessage.includes('token limit') ||
fullMessage.includes('subscription') ||
fullMessage.includes('429') ||
fullMessage.includes('403') ||
fullMessage.includes('quota')) {
console.log('SEOMetadataModal: Detected usage/subscription error (message match)', {
fullMessage,
err
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429,
@@ -343,7 +353,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from message)');
setIsGenerating(false);
return;
} else {
@@ -353,7 +363,6 @@ 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');
} finally {
setIsGenerating(false);
}
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);

View File

@@ -1,13 +1,21 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider, TextField } from '@mui/material';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, TextField, Chip } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
MoreHoriz as MoreHorizIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
FactCheck as FactCheckIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
import EditorSidebar from './EditorSidebar';
import HoverMenu from './HoverMenu';
import { useMarkdownProcessor } from '../../../hooks/useMarkdownProcessor';
import BlogPreviewModal from '../BlogPreviewModal';
import PlayAllTTSButton from '../PlayAllTTSButton';
import OnThisPageNav from './OnThisPageNav';
const theme = createTheme({
typography: {
@@ -31,6 +39,8 @@ interface BlogEditorProps {
continuityRefresh?: number;
flowAnalysisResults?: any;
sectionImages?: Record<string, string>;
sourceMappingStats?: any;
groundingInsights?: any;
}
const BlogEditor: React.FC<BlogEditorProps> = ({
@@ -45,7 +55,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
onSave,
continuityRefresh,
flowAnalysisResults,
sectionImages = {}
sectionImages = {},
sourceMappingStats,
groundingInsights
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('');
@@ -58,8 +70,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const [editingTitle, setEditingTitle] = useState(false);
const [editingIntro, setEditingIntro] = useState(false);
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [currentSectionId, setCurrentSectionId] = useState<string | number | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const introInputRef = useRef<HTMLInputElement>(null);
const contentContainerRef = useRef<HTMLDivElement>(null);
const totalWords = useMemo(() =>
sections.reduce((sum, s) => sum + (s.content?.split(/\s+/).filter(Boolean).length || 0), 0),
@@ -68,6 +83,55 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const readingTime = useMemo(() => Math.max(1, Math.ceil(totalWords / 200)), [totalWords]);
// Initialize markdown processor for preview functionality
const sectionsForProcessor = useMemo(() => {
const result: Record<string, string> = {};
sections.forEach(s => {
result[s.id] = s.content || '';
});
return result;
}, [sections]);
const { convertMarkdownToHTML } = useMarkdownProcessor(outline, sectionsForProcessor);
// Track current section based on scroll position
useEffect(() => {
const container = contentContainerRef.current;
if (!container) return;
const handleScroll = () => {
const sectionElements = container.querySelectorAll('[data-section-id]');
let currentId: string | number | null = null;
sectionElements.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.top <= 150) {
currentId = el.getAttribute('data-section-id');
}
});
if (currentId) {
setCurrentSectionId(currentId);
}
};
container.addEventListener('scroll', handleScroll);
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [sections]);
// Navigate to section
const handleNavigateToSection = useCallback((sectionId: string | number) => {
const container = contentContainerRef.current;
if (!container) return;
const targetElement = container.querySelector(`[data-section-id="${sectionId}"]`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
@@ -220,16 +284,26 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
});
}, []);
const handleDeleteSection = useCallback((sectionId: any) => {
setSections(prev => prev.filter(s => s.id !== sectionId));
if (onContentUpdate) {
// Update parent with filtered sections
setTimeout(() => {
// Give React time to update state
}, 0);
}
}, [onContentUpdate]);
return (
<ThemeProvider theme={theme}>
<div className="min-h-screen bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex gap-8">
{/* Main editor column */}
<div className="flex-1 min-w-0 max-w-4xl">
<div className="flex-1 min-w-0 max-w-4xl" ref={contentContainerRef}>
<Paper elevation={0} className="bg-white p-8 md:p-10 rounded-xl border border-gray-200/60">
{/* Title */}
<div className="mb-6 pb-6 border-b border-gray-100">
<div className="mb-6 pb-6 border-b border-gray-100" data-section-id="title">
<div className="flex items-start gap-2 group">
{editingTitle ? (
<TextField
@@ -257,6 +331,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</h1>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mt-1 shrink-0 flex items-center gap-1">
<Tooltip title="Preview full blog">
<IconButton onClick={() => setShowPreviewModal(true)} size="small">
<VisibilityIcon className="text-green-600" fontSize="small"/>
</IconButton>
</Tooltip>
<Tooltip title="Title actions">
<IconButton size="small" onClick={(e) => setTitleMenuAnchor(e.currentTarget)}>
<MoreHorizIcon className="text-gray-400" fontSize="small"/>
@@ -278,7 +357,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
{/* Introduction */}
<div className="mt-4 group/intro">
<div className="mt-4 group/intro" data-section-id="intro">
<div className="flex items-start gap-2">
{editingIntro ? (
<TextField
@@ -333,45 +412,186 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const imageId = imageIdByIndex || outlineSection?.id || section.id;
const sectionImage = sectionImages?.[imageId] || null;
return (
<BlogSection
key={section.id}
{...section}
onContentUpdate={onContentUpdate}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
refreshToken={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
sectionImage={sectionImage}
/>
<div key={section.id} data-section-id={section.id}>
<BlogSection
{...section}
onContentUpdate={onContentUpdate}
onDeleteSection={handleDeleteSection}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
refreshToken={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
sectionImage={sectionImage}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</div>
);
})}
</div>
{/* Stats bar */}
<div className="mt-8 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between text-sm text-gray-400">
<div className="flex items-center gap-4">
<span>{sections.length} {sections.length === 1 ? 'section' : 'sections'}</span>
<span className="text-gray-300">|</span>
<span>{totalWords.toLocaleString()} words</span>
<span className="text-gray-300">|</span>
<span>{readingTime} min read</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100)}%` }}
/>
{/* Compact Stats Bar - Vertical Stack */}
<Paper elevation={0} sx={{
mt: 4,
p: 2,
borderRadius: 3,
border: '1px solid #e2e8f0',
bgcolor: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
background: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
}}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{/* Left: Stats */}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Tooltip title="Total sections in your blog">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#4f46e5', cursor: 'help' }}>
📊 {sections.length} {sections.length === 1 ? 'section' : 'sections'}
</span>
</Tooltip>
<span style={{ color: '#cbd5e1' }}></span>
<Tooltip title="Total word count across all sections">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#2563eb', cursor: 'help' }}>
📝 {totalWords.toLocaleString()} words
</span>
</Tooltip>
<span style={{ color: '#cbd5e1' }}></span>
<Tooltip title="Estimated reading time (200 words/minute)">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#d97706', cursor: 'help' }}>
{readingTime} min read
</span>
</Tooltip>
</div>
<span className="text-xs text-gray-400">
{totalWords > 0
? `${Math.round(Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100))}%`
: '0%'}
</span>
</div>
{/* Right: Circular Progress + Play All TTS */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{(() => {
const targetWords = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
const progress = targetWords > 0 ? Math.min(100, Math.round((totalWords / targetWords) * 100)) : 0;
const remaining = Math.max(0, targetWords - totalWords);
return (
<Tooltip
title={
<div style={{ padding: 4 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Writing Progress</div>
<div style={{ fontSize: '0.75rem' }}>
Completed: {totalWords.toLocaleString()} words<br/>
🎯 Target: {targetWords.toLocaleString()} words<br/>
📝 Remaining: {remaining.toLocaleString()} words<br/>
📊 Progress: {progress}%
</div>
</div>
}
arrow
placement="top"
>
<div style={{ position: 'relative', width: 56, height: 56, cursor: 'help' }}>
<svg width="56" height="56" style={{ transform: 'rotate(-90deg)' }}>
<circle
cx="28"
cy="28"
r="24"
fill="none"
stroke="#e2e8f0"
strokeWidth="4"
/>
<circle
cx="28"
cy="28"
r="24"
fill="none"
stroke={progress >= 90 ? '#10b981' : '#6366f1'}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 24}`}
strokeDashoffset={`${2 * Math.PI * 24 * (1 - progress / 100)}`}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
<span style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '0.75rem',
fontWeight: 700,
color: progress >= 90 ? '#10b981' : '#6366f1',
}}>
{progress}%
</span>
</div>
</Tooltip>
);
})()}
{/* Play All TTS Button */}
<PlayAllTTSButton
title={blogTitle}
introduction={introduction}
sections={sections.map(s => ({ title: s.title, content: s.content }))}
/>
</div>
</div>
</div>
{/* Research Tools - Compact Chips */}
{(research || sourceMappingStats || groundingInsights) && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #e2e8f0',
display: 'flex',
gap: 4,
flexWrap: 'wrap',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#64748b', marginRight: 4 }}>
🔬 Research Tools:
</span>
{research && (
<Chip
icon={<BarChartIcon />}
label="Keywords"
size="small"
onClick={() => console.log('Open keywords')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#e0e7ff' },
}}
/>
)}
{sourceMappingStats && (
<Chip
icon={<HubIcon />}
label={`Sources (${sourceMappingStats.total_sources || 0})`}
size="small"
onClick={() => console.log('Open sources')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#dbeafe' },
}}
/>
)}
{groundingInsights && (
<Chip
icon={<FactCheckIcon />}
label="Grounding"
size="small"
onClick={() => console.log('Open grounding')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#fef3c7' },
}}
/>
)}
</div>
)}
</Paper>
</Paper>
</div>
@@ -384,6 +604,15 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
</div>
{/* On This Page Navigation */}
<OnThisPageNav
title={blogTitle}
introduction={introduction}
sections={sections}
onNavigate={handleNavigateToSection}
currentSectionId={currentSectionId}
/>
{/* Title Selection Modal */}
<Dialog open={showTitleModal} onClose={() => setShowTitleModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
@@ -505,6 +734,19 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
<Button onClick={() => setShowIntroductionModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Full Blog Preview Modal */}
<BlogPreviewModal
isOpen={showPreviewModal}
onClose={() => setShowPreviewModal(false)}
title={blogTitle}
introduction={introduction}
sections={sections.map(s => ({
title: s.title,
content: s.content,
}))}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</div>
</ThemeProvider>
);

View File

@@ -6,21 +6,22 @@ import {
TextField,
Tooltip,
CircularProgress,
Divider
Divider,
Box
} from '@mui/material';
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
MoreHoriz as MoreHorizIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
import HoverMenu from './HoverMenu';
import { blogWriterApi } from '../../../services/blogWriterApi';
import { TextToSpeechButton } from '../../shared/TextToSpeechButton';
interface BlogSectionProps {
id: any;
@@ -36,11 +37,13 @@ interface BlogSectionProps {
targetWords: number;
};
onContentUpdate?: (sections: any[]) => void;
onDeleteSection?: (sectionId: any) => void;
expandedSections: Set<any>;
toggleSectionExpansion: (sectionId: any) => void;
refreshToken?: number;
flowAnalysisResults?: any;
sectionImage?: string;
convertMarkdownToHTML?: (md: string) => string;
}
const BlogSection: React.FC<BlogSectionProps> = ({
@@ -50,13 +53,16 @@ const BlogSection: React.FC<BlogSectionProps> = ({
sources,
outlineData,
onContentUpdate,
onDeleteSection,
expandedSections,
toggleSectionExpansion,
refreshToken,
flowAnalysisResults,
sectionImage
sectionImage,
convertMarkdownToHTML
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [sectionTitle, setSectionTitle] = useState(title);
const [content, setContent] = useState(initialContent);
const [isGenerating, setIsGenerating] = useState(false);
@@ -224,26 +230,187 @@ const BlogSection: React.FC<BlogSectionProps> = ({
{sectionTitle}
</h2>
)}
{/* Section Toolbar - Shows on hover, positioned next to title */}
<div
className="section-toolbar"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s ease',
pointerEvents: isHovered ? 'auto' : 'none',
}}
>
{/* Preview/Edit Toggle */}
{convertMarkdownToHTML && (
<Tooltip title={isPreviewing ? 'Edit content' : 'Preview content'}>
<IconButton
size="small"
onClick={() => setIsPreviewing(!isPreviewing)}
sx={{
width: 32,
height: 32,
bgcolor: isPreviewing ? '#4f46e5' : 'white',
color: isPreviewing ? 'white' : '#475569',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: isPreviewing ? '#4338ca' : '#f8fafc',
borderColor: isPreviewing ? '#4338ca' : '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}
>
{isPreviewing ? <EditIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
)}
{/* Copy Button */}
<Tooltip title="Copy section">
<IconButton size="small" sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#64748b',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#f8fafc',
borderColor: '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* More Actions */}
<Tooltip title="Section actions">
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#64748b',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#f8fafc',
borderColor: '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Delete Button */}
<Tooltip title="Delete section">
<IconButton size="small" onClick={() => {
if (window.confirm(`Are you sure you want to delete "${sectionTitle}"? This cannot be undone.`)) {
onDeleteSection?.(id);
}
}} sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#ef4444',
border: '1px solid #fecaca',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#fef2f2',
borderColor: '#fca5a5',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Text-to-Speech Button */}
{content && content.trim().length > 0 && (
<TextToSpeechButton
text={content}
size="small"
showSettings={false}
disabled={isPreviewing}
/>
)}
</div>
</div>
{sectionImage && (
{sectionImage && (
<div className="mb-4">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
<img
src={`data:image/png;base64,${sectionImage}`}
alt={`Cover image for ${sectionTitle}`}
src={sectionImage.startsWith('http') || sectionImage.startsWith('/api/') ? sectionImage : `data:image/png;base64,${sectionImage}`}
alt={`Image for ${sectionTitle}`}
className="w-full h-auto max-h-96 object-contain"
/>
</div>
</div>
)}
)}
{isGenerating ? (
<div className="flex items-center gap-3 p-6 bg-indigo-50/50 rounded-lg border border-indigo-100/50 mb-3">
<CircularProgress size={20} className="text-indigo-400" />
<span className="text-sm text-indigo-600 font-medium">Generating content...</span>
</div>
) : isPreviewing && convertMarkdownToHTML ? (
// Preview Mode
<div className="relative">
<Box
className="preview-content"
sx={{
p: 3,
bgcolor: '#fafbfc',
borderRadius: 2,
border: '1px solid #e5e7eb',
fontFamily: 'Georgia, serif',
lineHeight: 1.8,
color: '#1f2937',
'& h1, & h2, & h3': { color: '#111827', mt: 2, mb: 1 },
'& h2': { fontSize: '1.5rem', fontWeight: 600, borderBottom: '1px solid #e5e7eb', pb: 1 },
'& p': { mb: 1.5 },
'& strong': { fontWeight: 600 },
'& em': { fontStyle: 'italic' },
'& a': { color: '#4f46e5', textDecoration: 'underline' },
'& blockquote': {
borderLeft: '4px solid #e5e7eb',
pl: 2,
py: 1,
color: '#6b7280',
fontStyle: 'italic',
bgcolor: '#f9fafb',
},
'& code': {
bgcolor: '#f1f5f9',
px: 1,
py: 0.5,
borderRadius: 0.25,
fontFamily: 'monospace',
fontSize: '0.9em',
},
'& ul, & ol': { pl: 2, mb: 1.5 },
'& li': { mb: 0.5 },
'& hr': { borderColor: '#e5e7eb', my: 2 },
'& img': { maxWidth: '100%', height: 'auto', borderRadius: 1 },
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(content) }}
/>
</div>
) : (
// Edit Mode
<div className="relative">
<TextField
multiline
@@ -332,36 +499,40 @@ const BlogSection: React.FC<BlogSectionProps> = ({
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between mt-2">
{/* Bottom word count - compact */}
<div className="flex items-center justify-between mt-2" style={{ opacity: isHovered || isFocused ? 1 : 0, transition: 'opacity 0.2s' }}>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">{wordCount_} words</span>
<span className="text-xs" style={{ fontWeight: 600, color: '#94a3b8' }}>
📝 {wordCount_} words
</span>
{outlineData?.targetWords && outlineData.targetWords > 0 && (
<>
<span className="text-gray-300 text-xs">/</span>
<span className="text-xs text-gray-400">{outlineData.targetWords} target</span>
<span className="text-xs" style={{
fontWeight: 600,
color: wordCount_ >= outlineData.targetWords * 0.9 ? '#10b981' : '#94a3b8',
}}>
{outlineData.targetWords} target
</span>
</>
)}
</div>
<div className="flex items-center gap-0.5" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s' }}>
<div className="flex items-center gap-1">
{outlineData && (
<Tooltip title={expandedSections.has(id) ? 'Hide outline info' : 'Show outline info'}>
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{ width: 28, height: 28 }}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{
width: 28,
height: 28,
bgcolor: 'transparent',
color: '#64748b',
'&:hover': {
bgcolor: '#f1f5f9',
},
}}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 14 }} /> : <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
<Tooltip title="Section actions">
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{ width: 28, height: 28 }}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<DeleteOutlineIcon sx={{ fontSize: 16, color: '#9ca3af' }} />
</IconButton>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import { chartApi, ChartGenerateResponse } from '../../../services/chartApi';
import TextSelectionMenu from './TextSelectionMenu';
import ChartGeneratorModal from '../../Chart/ChartGeneratorModal';
import LinkSearchModal from '../../Link/LinkSearchModal';
import useSmartTypingAssist from './SmartTypingAssist';
// import { debug } from '../../../utils/debug'; // Unused import
@@ -17,6 +20,11 @@ const useBlogTextSelectionHandler = (
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
const [chartModalOpen, setChartModalOpen] = useState(false);
const [chartModalText, setChartModalText] = useState('');
const [chartResult, setChartResult] = useState<(ChartGenerateResponse & { sectionId?: string }) | null>(null);
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [linkModalText, setLinkModalText] = useState('');
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use the extracted smart typing assist hook
@@ -108,6 +116,38 @@ const useBlogTextSelectionHandler = (
setFactCheckResults(null);
};
// Chart generation handler
const handleGenerateChart = (text: string) => {
setChartModalText(text);
setChartModalOpen(true);
setSelectionMenu(null);
};
const handleChartGenerated = (result: ChartGenerateResponse & { sectionId?: string }) => {
setChartResult(result);
setChartModalOpen(false);
};
const handleFindLinks = (text: string) => {
setLinkModalText(text);
setLinkModalOpen(true);
setSelectionMenu(null);
};
const handleLinkRewordAccept = (rewordedText: string, sectionId?: string) => {
if (onTextReplace && linkModalText) {
onTextReplace(linkModalText, rewordedText, 'reword-with-links');
}
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: {
originalText: linkModalText,
editedText: rewordedText,
editType: 'reword-with-links'
}
}));
setLinkModalOpen(false);
};
// Blog-specific quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
@@ -273,7 +313,8 @@ const useBlogTextSelectionHandler = (
...smartTypingAssist,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<TextSelectionMenu
<>
<TextSelectionMenu
selectionMenu={selectionMenu}
factCheckResults={factCheckResults}
isFactChecking={isFactChecking}
@@ -284,6 +325,8 @@ const useBlogTextSelectionHandler = (
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onGenerateChart={handleGenerateChart}
onFindLinks={handleFindLinks}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
@@ -292,6 +335,25 @@ const useBlogTextSelectionHandler = (
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
/>
{chartModalOpen && (
<ChartGeneratorModal
isOpen={chartModalOpen}
onClose={() => setChartModalOpen(false)}
defaultText={chartModalText}
onChartGenerated={handleChartGenerated}
/>
)}
{linkModalOpen && (
<LinkSearchModal
isOpen={linkModalOpen}
onClose={() => setLinkModalOpen(false)}
sectionHeading=""
sectionText={linkModalText}
selectedText={linkModalText}
onRewordAccept={handleLinkRewordAccept}
/>
)}
</>
)
};
};

View File

@@ -13,30 +13,10 @@ interface EditorSidebarProps {
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
const wordTarget = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
const progress = wordTarget > 0 ? Math.min(100, Math.round((totalWords / wordTarget) * 100)) : 0;
return (
<div>
<Paper elevation={0} className="p-5 rounded-xl border border-gray-200/60 bg-white">
{/* Progress ring */}
<div className="text-center mb-5">
<div className="relative inline-flex items-center justify-center">
<svg className="w-20 h-20 -rotate-90">
<circle cx="40" cy="40" r="34" fill="none" stroke="#f3f4f6" strokeWidth="4" />
<circle
cx="40" cy="40" r="34"
fill="none" stroke="#4f46e5" strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 34}`}
strokeDashoffset={`${2 * Math.PI * 34 * (1 - progress / 100)}`}
className="transition-all duration-500"
/>
</svg>
<span className="absolute text-lg font-bold text-gray-700">{progress}%</span>
</div>
<div className="mt-2 text-xs text-gray-400">content complete</div>
</div>
{/* Stats */}
<div className="space-y-2 mb-5">
<div className="flex justify-between text-sm">

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Paper, Typography, Box, Tooltip } from '@mui/material';
import { Navigation as NavigationIcon } from '@mui/icons-material';
interface Section {
id: string | number;
title: string;
}
interface OnThisPageNavProps {
title: string;
introduction: string;
sections: Section[];
onNavigate: (sectionId: string | number) => void;
currentSectionId?: string | number | null;
}
const OnThisPageNav: React.FC<OnThisPageNavProps> = ({
title,
introduction,
sections,
onNavigate,
currentSectionId,
}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const allItems = React.useMemo(() => {
const items: Array<{ id: string | number; label: string; type: 'title' | 'intro' | 'section' }> = [];
if (title) {
items.push({ id: 'title', label: title, type: 'title' });
}
if (introduction && introduction.trim()) {
items.push({ id: 'intro', label: 'Introduction', type: 'intro' });
}
sections.forEach((section, index) => {
items.push({
id: section.id,
label: section.title || `Section ${index + 1}`,
type: 'section'
});
});
return items;
}, [title, introduction, sections]);
if (allItems.length === 0) {
return null;
}
return (
<Paper
elevation={0}
sx={{
position: 'fixed',
right: isCollapsed ? 0 : 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
transition: 'all 0.3s ease',
borderRadius: isCollapsed ? '12px 0 0 12px' : '12px 0 0 12px',
border: '1px solid #e2e8f0',
borderRight: 'none',
bgcolor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
maxWidth: isCollapsed ? 40 : 240,
overflow: 'hidden',
boxShadow: isHovered ? '0 8px 24px rgba(0,0,0,0.12)' : '0 2px 8px rgba(0,0,0,0.08)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Toggle Button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'space-between',
p: 1,
borderBottom: '1px solid #e2e8f0',
cursor: 'pointer',
bgcolor: '#f8fafc',
}}
onClick={() => setIsCollapsed(!isCollapsed)}
>
{!isCollapsed && (
<Typography variant="caption" sx={{ fontWeight: 600, color: '#4f46e5', fontSize: '0.7rem' }}>
On This Page
</Typography>
)}
<Tooltip title={isCollapsed ? 'Expand' : 'Collapse'}>
<NavigationIcon
sx={{
fontSize: 16,
color: '#4f46e5',
transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
}}
/>
</Tooltip>
</Box>
{/* Navigation Items */}
{!isCollapsed && (
<Box
sx={{
p: 1,
maxHeight: '60vh',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: 4,
},
'&::-webkit-scrollbar-thumb': {
bgcolor: '#cbd5e1',
borderRadius: 2,
},
}}
>
{allItems.map((item, index) => {
const isActive = currentSectionId === item.id;
return (
<Box
key={`${item.type}-${item.id}`}
onClick={() => onNavigate(item.id)}
sx={{
py: 0.75,
px: 1.5,
mb: 0.5,
borderRadius: 1,
cursor: 'pointer',
transition: 'all 0.2s ease',
borderLeft: isActive ? '3px solid #4f46e5' : '3px solid transparent',
bgcolor: isActive ? 'rgba(79, 70, 229, 0.08)' : 'transparent',
'&:hover': {
bgcolor: 'rgba(79, 70, 229, 0.05)',
borderLeftColor: '#6366f1',
},
}}
>
<Typography
variant="caption"
sx={{
fontSize: '0.75rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : '#64748b',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.type === 'title' && '📝 '}
{item.type === 'intro' && '📖 '}
{item.label}
</Typography>
</Box>
);
})}
</Box>
)}
</Paper>
);
};
export default OnThisPageNav;

View File

@@ -36,6 +36,8 @@ interface TextSelectionMenuProps {
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onGenerateChart: (text: string) => void;
onFindLinks: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
@@ -56,6 +58,8 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onGenerateChart,
onFindLinks,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
@@ -147,6 +151,72 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
)}
</button>
{/* Generate Chart Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onGenerateChart(selectionMenu.text);
}}
style={{
background: 'rgba(124, 58, 237, 0.2)',
border: '1px solid rgba(124, 58, 237, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)';
}}
>
📊 Generate Chart
</button>
{/* Find Links Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindLinks(selectionMenu.text);
}}
style={{
background: 'rgba(16, 185, 129, 0.2)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.2)';
}}
>
🔗 Find Links
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',