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:
229
frontend/src/components/BlogWriter/BlogPreviewModal.tsx
Normal file
229
frontend/src/components/BlogWriter/BlogPreviewModal.tsx
Normal 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 • 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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
280
frontend/src/components/BlogWriter/BrainstormButton.tsx
Normal file
280
frontend/src/components/BlogWriter/BrainstormButton.tsx
Normal 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
497
frontend/src/components/BlogWriter/GSCBrainstormModal.tsx
Normal file
497
frontend/src/components/BlogWriter/GSCBrainstormModal.tsx
Normal 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} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
||||
{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 · 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} · {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;
|
||||
@@ -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;
|
||||
@@ -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...';
|
||||
}
|
||||
|
||||
245
frontend/src/components/BlogWriter/PlayAllTTSButton.tsx
Normal file
245
frontend/src/components/BlogWriter/PlayAllTTSButton.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
167
frontend/src/components/BlogWriter/WYSIWYG/OnThisPageNav.tsx
Normal file
167
frontend/src/components/BlogWriter/WYSIWYG/OnThisPageNav.tsx
Normal 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;
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user