fix: WYSIWYG editor, content generation, and writing assistant bug fixes

- Fix text selection menu not showing: wire contentRef via inputRef on multiline TextField
- Fix blog title not truncating: add min-w-0 for flex item overflow
- Fix outline generation 500: escape curly braces in f-string prompt template
- Fix content generation 'NoneType not callable': replace SessionLocal() with get_session_for_user(), add db param to MediumBlogGenerator, fix signature mismatch in database_task_manager
- Fix writing assistant suggest 500: add auth + user_id to API endpoint and service, replace sync requests with httpx.AsyncClient
- Fix hallucination detector 404: explicitly include router in main.py and app.py
- Fix missing error_data in task failure responses
- Hide CopilotKit web inspector button
- Remove hardcoded fallback suggestions from SmartTypingAssist
- Fix stale closure refs in SmartTypingAssist handleTypingChange
- Add two-column editor layout, stats bar, section hover menu
- Various subscription, billing, and research module improvements
This commit is contained in:
ajaysi
2026-05-14 09:11:30 +05:30
parent 7385100017
commit 928c2f20aa
113 changed files with 4344 additions and 10064 deletions

View File

@@ -5,7 +5,7 @@
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
import { apiClient } from './client';
import { isPodcastOnlyDemoMode } from '../utils/demoMode';
import { isFeatureOnlyMode } from '../utils/demoMode';
export interface ProviderAvailability {
google_available: boolean;
@@ -130,9 +130,9 @@ let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
* and research persona from the unified /api/research/config endpoint.
*/
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
// Skip in podcast-only mode — backend always provides AI-generated research_queries
if (isPodcastOnlyDemoMode()) {
throw new Error('Research config not available in podcast-only mode');
// Skip in feature-limited mode — backend always provides AI-generated research_queries
if (isFeatureOnlyMode()) {
throw new Error('Research config not available in feature-limited mode');
}
// If a request is already in flight, return the same promise

View File

@@ -5,7 +5,6 @@ import { CopilotKit } from "@copilotkit/react-core";
import { CopilotKitHealthProvider } from '../../contexts/CopilotKitHealthContext';
import CopilotKitDegradedBanner from '../shared/CopilotKitDegradedBanner';
import ErrorBoundary from '../shared/ErrorBoundary';
import { isPodcastOnlyDemoMode } from '../../utils/demoMode';
interface ConditionalCopilotKitProps {
children: React.ReactNode;
@@ -24,10 +23,12 @@ export const AuthenticatedCopilotWrapper: React.FC<AuthenticatedCopilotWrapperPr
const { isSignedIn } = useAuth();
const location = useLocation();
const isPodcastOnly = isPodcastOnlyDemoMode();
const shouldExcludeCopilot = !isSignedIn || location.pathname.startsWith('/onboarding') || isPodcastOnly;
// Only fully exclude CopilotKit when user is not signed in or on onboarding
// Feature-limited mode (blog_writer, etc.) still needs CopilotKit providers
// because BlogWriter uses useCopilotAction and useCopilotKitHealth hooks
const shouldExcludeCopilotKit = !isSignedIn || location.pathname.startsWith('/onboarding');
if (shouldExcludeCopilot) {
if (shouldExcludeCopilotKit) {
return <>{children}</>;
}

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useOnboarding } from '../../contexts/OnboardingContext';
import { useSubscription } from '../../contexts/SubscriptionContext';
import { useOAuthTokenAlerts } from '../../hooks/useOAuthTokenAlerts';
import { shouldSkipOnboarding } from '../../utils/demoMode';
import { shouldSkipOnboarding, getDefaultLandingRoute, isFeatureOnlyMode, getSingleFeature } from '../../utils/demoMode';
import { restoreNavigationState } from '../../utils/navigationState';
import ConnectionErrorPage from '../shared/ConnectionErrorPage';
const CHECKOUT_POLL_INTERVAL_MS = 2000;
const CHECKOUT_POLL_MAX_ATTEMPTS = 10;
const InitialRouteHandler: React.FC = () => {
// Helper to log and navigate in a single place
const navigateAndLog = (to: string) => {
console.log(`InitialRouteHandler: Redirecting to ${to}`);
return <Navigate to={to} replace />;
@@ -23,12 +26,24 @@ const InitialRouteHandler: React.FC = () => {
hasError: false,
error: null,
});
// Post-checkout polling state
const [checkoutPolling, setCheckoutPolling] = useState(false);
const checkoutPollAttempts = useRef(0);
// Track whether the initial subscription check has completed
// Prevents premature routing decisions before we know the user's plan
const [initialCheckDone, setInitialCheckDone] = useState(false);
const urlParams = new URLSearchParams(location.search);
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
const returnTo = urlParams.get('return_to');
useOAuthTokenAlerts({
enabled: subscription?.active === true,
interval: 60000,
});
// Initial subscription check with retries
useEffect(() => {
const timeoutId = setTimeout(async () => {
const maxRetries = 3;
@@ -38,47 +53,91 @@ const InitialRouteHandler: React.FC = () => {
break;
} catch (err) {
console.error(`App: Subscription check attempt ${attempt + 1} failed:`, err);
const isConnectionError = err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError');
if (isConnectionError && attempt < maxRetries - 1) {
const delay = 1000 * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (attempt === maxRetries - 1 || !isConnectionError) {
if (isConnectionError) {
setConnectionError({
hasError: true,
error: err as Error,
});
}
}
const delay = 1000 * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (attempt === maxRetries - 1 || !isConnectionError) {
if (isConnectionError) {
setConnectionError({
hasError: true,
error: err as Error,
});
}
}
}
}
// Mark initial check as done regardless of success/failure
setInitialCheckDone(true);
}, 100);
return () => clearTimeout(timeoutId);
}, []);
const urlParams = new URLSearchParams(location.search);
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
// Handle post-checkout: when Stripe redirects back with ?subscription=success,
// the webhook may not have processed yet. Poll until subscription becomes active.
useEffect(() => {
if (!isCheckoutSuccess) return;
if (subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Webhook has processed — subscription is active, stop polling
if (checkoutPolling) {
console.log('InitialRouteHandler: Checkout success — subscription confirmed active, stopping poll');
setCheckoutPolling(false);
}
return;
}
// Start polling if webhook hasn't processed yet
if (!checkoutPolling && checkoutPollAttempts.current === 0) {
console.log('InitialRouteHandler: Checkout success — subscription not yet active, starting poll');
setCheckoutPolling(true);
}
}, [isCheckoutSuccess, subscription, checkoutPolling]);
// Polling effect for post-checkout
useEffect(() => {
if (!checkoutPolling) return;
if (checkoutPollAttempts.current >= CHECKOUT_POLL_MAX_ATTEMPTS) {
console.log('InitialRouteHandler: Checkout polling exhausted — proceeding with current state');
setCheckoutPolling(false);
return;
}
const timer = setTimeout(async () => {
checkoutPollAttempts.current += 1;
console.log(`InitialRouteHandler: Checkout poll attempt ${checkoutPollAttempts.current}/${CHECKOUT_POLL_MAX_ATTEMPTS}`);
try {
await checkSubscription();
} catch (err) {
console.error('InitialRouteHandler: Checkout poll check failed:', err);
}
}, CHECKOUT_POLL_INTERVAL_MS);
return () => clearTimeout(timer);
}, [checkoutPolling, checkSubscription]);
// Initialize onboarding when subscription is confirmed (but not on checkout success — let redirect happen)
useEffect(() => {
if (subscription && !subscriptionLoading) {
const isNewUser = !subscription || subscription.plan === 'none';
console.log('InitialRouteHandler: Subscription data received:', {
plan: subscription.plan,
active: subscription.active,
isNewUser,
subscriptionLoading
subscriptionLoading,
isCheckoutSuccess,
});
if (subscription.active && !isNewUser) {
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
if (!isCheckoutSuccess) {
initializeOnboarding();
}
@@ -86,9 +145,85 @@ const InitialRouteHandler: React.FC = () => {
}
}, [subscription, subscriptionLoading, initializeOnboarding, isCheckoutSuccess]);
if (isCheckoutSuccess && subscription?.active && shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Early redirect - Stripe checkout success in demo mode → Podcast Maker');
return navigateAndLog("/podcast-maker");
// --- Render decisions ---
// Wait for initial subscription check before making routing decisions.
// Without this, a null subscription (before API response) can trigger
// incorrect redirects (e.g., to feature routes instead of /pricing).
if (!initialCheckDone && !connectionError.hasError) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
// Show polling spinner during post-checkout webhook wait
if (checkoutPolling) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Activating your subscription...
</Typography>
<Typography variant="body2" color="textSecondary">
This may take a few seconds.
</Typography>
</Box>
);
}
// Post-checkout: subscription is now active (or poll exhausted)
if (isCheckoutSuccess && subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Restore navigation state (saved before Stripe redirect)
const navState = restoreNavigationState();
const redirectTo = returnTo || navState?.path;
if (redirectTo && redirectTo !== '/pricing' && redirectTo !== '/onboarding') {
console.log(`InitialRouteHandler: Checkout success — redirecting to saved page: ${redirectTo}`);
return navigateAndLog(redirectTo);
}
if (shouldSkipOnboarding()) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Checkout success in demo mode → ${route}`);
return navigateAndLog(route);
}
if (!isOnboardingComplete) {
console.log('InitialRouteHandler: Checkout success — onboarding incomplete → Onboarding');
return navigateAndLog('/onboarding');
}
console.log('InitialRouteHandler: Checkout success → Dashboard');
return navigateAndLog('/dashboard');
}
// Checkout success but subscription still not active after polling — treat as inactive
// SubscriptionContext will show the expired modal
if (isCheckoutSuccess && (!subscription?.active || subscription.plan === 'none' || subscription.plan === 'free')) {
console.log('InitialRouteHandler: Checkout success but subscription not yet active — showing pricing');
if (shouldSkipOnboarding()) {
return navigateAndLog(getDefaultLandingRoute());
}
return <Navigate to="/pricing" replace />;
}
if (connectionError.hasError) {
@@ -128,9 +263,9 @@ const InitialRouteHandler: React.FC = () => {
subscription: subscription ? { plan: subscription.plan, active: subscription.active } : null,
subscriptionLoading,
loading,
data: !!data
data: !!data,
});
const isActiveSubscriber = Boolean(subscription && subscription.active && subscription.plan !== 'none');
const isActiveSubscriber = Boolean(subscription && subscription.active && subscription.plan !== 'none' && subscription.plan !== 'free');
console.log('InitialRouteHandler: isActiveSubscriber =', isActiveSubscriber);
const waitingForOnboardingInit = !isDemoMode && isActiveSubscriber && (loading || !data);
if (waitingForOnboardingInit) {
@@ -192,10 +327,15 @@ const InitialRouteHandler: React.FC = () => {
if (!subscription) {
if (isOnboardingComplete) {
if (isDemoMode) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Onboarding complete, no sub, demo mode → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
return navigateAndLog("/dashboard");
}
if (subscriptionLoading) {
return (
<Box
@@ -213,43 +353,19 @@ const InitialRouteHandler: React.FC = () => {
</Box>
);
}
if (!subscription) {
if (isOnboardingComplete) {
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
return navigateAndLog("/dashboard");
}
if (subscriptionLoading) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Checking subscription...
</Typography>
</Box>
);
}
if (shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Demo mode - no subscription but allowing access to podcast-maker');
return navigateAndLog("/podcast-maker");
}
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
return navigateAndLog("/pricing");
if (shouldSkipOnboarding()) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Demo mode - no subscription but allowing access to ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
return navigateAndLog("/pricing");
}
const isNewUser = !subscription || subscription.plan === 'none';
const isNewUser = !subscription || subscription.plan === 'none' || subscription.plan === 'free';
if (isNewUser || !subscription.active) {
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
if (isNewUser) {
@@ -262,15 +378,21 @@ const InitialRouteHandler: React.FC = () => {
if (!isOnboardingComplete) {
console.log('InitialRouteHandler: isOnboardingComplete = false, shouldSkipOnboarding() =', shouldSkipOnboarding());
if (shouldSkipOnboarding()) {
console.log('InitialRouteHandler: Demo mode - skipping onboarding → Podcast Maker');
return navigateAndLog("/podcast-maker");
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: Demo mode - skipping onboarding → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
return navigateAndLog("/onboarding");
}
if (isDemoMode) {
const route = getDefaultLandingRoute();
console.log(`InitialRouteHandler: All set in demo mode → ${route}`);
return navigateAndLog(route);
}
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
return navigateAndLog("/dashboard");
};
export default InitialRouteHandler;
export default InitialRouteHandler;

View File

@@ -1,4 +1,11 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { debug } from '../../utils/debug';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi } from '../../services/blogWriterApi';
@@ -28,7 +35,7 @@ import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
export const BlogWriter: React.FC = () => {
const BlogWriter: React.FC = () => {
// Add light theme class to body/html on mount, remove on unmount
React.useEffect(() => {
document.body.classList.add('blog-writer-page');
@@ -44,6 +51,8 @@ export const BlogWriter: React.FC = () => {
enabled: true, // Enable health checking
});
const navigate = useNavigate();
// Use custom hook for all state management
const {
research,
@@ -67,6 +76,7 @@ export const BlogWriter: React.FC = () => {
flowAnalysisCompleted,
flowAnalysisResults,
sectionImages,
setResearch,
setOutline,
setTitleOptions,
setSelectedTitle,
@@ -77,6 +87,7 @@ export const BlogWriter: React.FC = () => {
setContinuityRefresh,
setOutlineTaskId,
setContentConfirmed,
setOutlineConfirmed,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
setSectionImages,
@@ -291,6 +302,48 @@ export const BlogWriter: React.FC = () => {
}
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const handleNewBlog = useCallback(() => {
setResearch(null);
setOutline([]);
setSections({});
setSeoAnalysis(null);
setSeoMetadata(null);
setContentConfirmed(false);
setOutlineConfirmed(false);
setSelectedTitle('');
setTitleOptions([]);
setCurrentPhase('');
try {
localStorage.removeItem('blog_outline');
localStorage.removeItem('blog_title_options');
localStorage.removeItem('blog_selected_title');
localStorage.removeItem('blogwriter_current_phase');
localStorage.removeItem('blogwriter_user_selected_phase');
localStorage.removeItem('blog_content_confirmed');
localStorage.removeItem('blog_seo_recommendations_applied');
} catch {
// ignore localStorage errors
}
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
setCurrentPhase]);
const handleMyBlogs = useCallback(() => {
navigate('/asset-library?source_module=blog_writer&asset_type=text');
}, [navigate]);
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
const confirmNewBlog = useCallback(() => {
if (hasExistingWork) {
setNewBlogDialogOpen(true);
} else {
handleNewBlog();
}
}, [hasExistingWork, handleNewBlog]);
const outlineGenRef = useRef<any>(null);
// Callback to handle cached outline completion
@@ -332,6 +385,7 @@ export const BlogWriter: React.FC = () => {
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onResearchComplete: handleResearchComplete,
onOutlineComplete: handleCachedOutlineComplete,
onContentComplete: handleCachedContentComplete,
});
@@ -443,6 +497,7 @@ export const BlogWriter: React.FC = () => {
/>
{/* Phase navigation header - always visible as default interface */}
<div style={{ flexShrink: 0 }}>
<HeaderBar
phases={phases}
currentPhase={currentPhase}
@@ -464,7 +519,11 @@ export const BlogWriter: React.FC = () => {
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
onNewBlog={confirmNewBlog}
onMyBlogs={handleMyBlogs}
onHelp={() => window.open('/docs', '_blank')}
/>
</div>
{/* Landing section - extracted to BlogWriterLandingSection */}
<BlogWriterLandingSection
@@ -560,6 +619,26 @@ export const BlogWriter: React.FC = () => {
// Publisher component will use this metadata when calling publish API
}}
/>
{/* New Blog confirmation dialog */}
<Dialog
open={newBlogDialogOpen}
onClose={() => setNewBlogDialogOpen(false)}
aria-labelledby="new-blog-dialog-title"
>
<DialogTitle id="new-blog-dialog-title">Start New Blog?</DialogTitle>
<DialogContent>
<DialogContentText>
This will clear all your current work and start a new blog. This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setNewBlogDialogOpen(false)}>Cancel</Button>
<Button onClick={() => { handleNewBlog(); setNewBlogDialogOpen(false); }} color="primary" variant="contained">
Start New
</Button>
</DialogActions>
</Dialog>
</div>
);
};

View File

@@ -1,4 +1,21 @@
import React from 'react';
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import ArticleIcon from '@mui/icons-material/Article';
import HelpIcon from '@mui/icons-material/Help';
import HeaderControls from '../../shared/HeaderControls';
import PhaseNavigation, { PhaseActionHandlers } from '../PhaseNavigation';
interface HeaderBarProps {
@@ -15,61 +32,154 @@ interface HeaderBarProps {
hasSEOAnalysis?: boolean;
seoRecommendationsApplied?: boolean;
hasSEOMetadata?: boolean;
onNewBlog?: () => void;
onMyBlogs?: () => void;
onHelp?: () => void;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({
phases,
currentPhase,
onPhaseClick,
copilotKitAvailable = true,
actionHandlers,
hasResearch = false,
hasOutline = false,
outlineConfirmed = false,
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
seoRecommendationsApplied = false,
hasSEOMetadata = false,
phases, currentPhase, onPhaseClick, copilotKitAvailable = true, actionHandlers,
hasResearch = false, hasOutline = false, outlineConfirmed = false,
hasContent = false, contentConfirmed = false, hasSEOAnalysis = false,
seoRecommendationsApplied = false, hasSEOMetadata = false,
onNewBlog, onMyBlogs, onHelp,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(anchorEl);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => setAnchorEl(event.currentTarget);
const handleMenuClose = () => setAnchorEl(null);
const handleNewBlog = () => { handleMenuClose(); onNewBlog?.(); };
const handleMyBlogs = () => { handleMenuClose(); onMyBlogs?.(); };
const handleHelp = () => { handleMenuClose(); onHelp?.(); };
return (
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
fontWeight: 'bold',
color: '#666'
}}>
A
</div>
</div>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={hasSEOMetadata}
/>
</div>
<Box sx={{
width: '100%',
background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%)',
borderRadius: 3,
p: { xs: 1.5, md: 2.5 },
border: '1px solid rgba(37, 99, 235, 0.15)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0, left: 0, right: 0,
height: '3px',
background: 'linear-gradient(90deg, #2563eb 0%, #3b82f6 50%, #60a5fa 100%)',
},
}}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
<Stack direction="row" alignItems="center" gap={1.5}>
<Box sx={{
width: { xs: 36, md: 44 },
height: { xs: 36, md: 44 },
borderRadius: 2,
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(37, 99, 235, 0.3)',
}}>
<ArticleIcon sx={{ color: '#fff', fontSize: { xs: 20, md: 24 } }} />
</Box>
<Typography variant="h5" sx={{
background: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 700,
fontSize: { xs: '1.1rem', sm: '1.25rem', md: '1.5rem' },
letterSpacing: '-0.02em',
}}>
AI Blog Writer
</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<IconButton onClick={handleMenuOpen} sx={{
background: isMenuOpen
? 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)'
: 'rgba(37, 99, 235, 0.1)',
border: '1px solid',
borderColor: isMenuOpen ? 'transparent' : 'rgba(37, 99, 235, 0.3)',
borderRadius: 2,
p: 1,
transition: 'all 0.2s ease',
'&:hover': {
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
borderColor: 'transparent',
transform: 'scale(1.05)',
},
}}>
{isMenuOpen ? <CloseIcon sx={{ color: '#fff', fontSize: 20 }} /> : <MenuIcon sx={{ color: '#2563eb', fontSize: 20 }} />}
</IconButton>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
mt: 1, minWidth: 220, borderRadius: 2,
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
border: '1px solid rgba(37, 99, 235, 0.3)',
boxShadow: '0 10px 40px rgba(37, 99, 235, 0.25)',
'& .MuiMenuItem-root': {
color: 'rgba(255, 255, 255, 0.85)',
px: 2, py: 1.5,
transition: 'all 0.15s ease',
'&:hover': {
background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.2) 0%, rgba(59, 130, 246, 0.2) 100%)',
color: '#fff',
},
},
'& .MuiListItemIcon-root': { color: '#60a5fa', minWidth: 36 },
'& .MuiDivider-root': { borderColor: 'rgba(37, 99, 235, 0.2)', my: 0.5 },
},
}}
>
<MenuItem onClick={handleNewBlog}>
<ListItemIcon><AddIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="New Blog" primaryTypographyProps={{ fontWeight: 600 }} />
</MenuItem>
<MenuItem onClick={handleMyBlogs}>
<ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="My Blogs" primaryTypographyProps={{ fontWeight: 500 }} />
</MenuItem>
<Divider />
<MenuItem onClick={handleHelp}>
<ListItemIcon><HelpIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="Help & Docs" primaryTypographyProps={{ fontWeight: 500 }} />
</MenuItem>
</Menu>
</Stack>
</Stack>
<Box sx={{ mt: 1 }}>
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={hasSEOMetadata}
/>
</Box>
</Box>
);
};
export default HeaderBar;

View File

@@ -198,6 +198,18 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
* {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Hide CopilotKit announcement/notification icon (bell badge) */
[class*="announcement"] {
display: none !important;
}
[class*="announce"] {
display: none !important;
}
/* Hide the floating Web Inspector button (shadow DOM - target the custom element itself) */
cpk-web-inspector {
display: none !important;
}
`}</style>
{/* Inject data attributes to identify Next suggestions */}

View File

@@ -20,6 +20,7 @@ interface UsePhaseActionHandlersProps {
setIsSEOAnalysisModalOpen: (open: boolean) => void;
setIsSEOMetadataModalOpen: (open: boolean) => void;
runSEOAnalysisDirect: () => string;
onResearchComplete?: (research: any) => void;
onOutlineComplete?: (outline: any) => void;
onContentComplete?: (sections: Record<string, string>) => void;
}
@@ -40,14 +41,32 @@ export const usePhaseActionHandlers = ({
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onResearchComplete,
onOutlineComplete,
onContentComplete,
}: UsePhaseActionHandlersProps) => {
const handleResearchAction = useCallback(() => {
if (research) {
navigateToPhase('research');
return;
}
const cachedEntries = researchCache.getAllCachedEntries();
const latestCached = cachedEntries.find(entry => {
try {
return new Date(entry.expires_at) > new Date();
} catch {
return false;
}
});
if (latestCached && onResearchComplete) {
debug.log('[BlogWriter] Restoring cached research data', { keywords: latestCached.keywords });
onResearchComplete(latestCached.result);
}
navigateToPhase('research');
debug.log('[BlogWriter] Research action triggered - navigating to research phase');
// Note: Research caching is handled by ManualResearchForm component
}, [navigateToPhase]);
}, [navigateToPhase, onResearchComplete, research]);
const handleOutlineAction = useCallback(async () => {
if (!research) {

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { hashContent, getSeoCacheKey } from '../../../utils/contentHash';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
import { blogWriterCache } from '../../../services/blogWriterCache';
@@ -218,6 +219,44 @@ export const useSEOManager = ({
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Restore cached SEO analysis on mount when sections are available
useEffect(() => {
const restoreCachedSEO = async () => {
if (seoAnalysis) return;
const title = selectedTitle || '';
if (!title && (!outline || outline.length === 0)) return;
const fullMarkdown = (outline || []).map(s => `## ${s.heading}\n\n${(sections || {})[s.id] || ''}`).join('\n\n');
if (!fullMarkdown && !title) return;
try {
const hash = await hashContent(`${title}\n${fullMarkdown}`);
const cacheKey = getSeoCacheKey(hash, title);
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 });
setSeoAnalysis(parsed);
}
}
} catch (e) {
debug.log('[SEOManager] Failed to restore cached SEO analysis', e);
}
};
restoreCachedSEO();
try {
const wasApplied = localStorage.getItem('blog_seo_recommendations_applied') === 'true';
if (wasApplied) {
setSeoRecommendationsApplied(true);
debug.log('[SEOManager] Restored seoRecommendationsApplied flag');
}
} catch {}
}, [selectedTitle, sections, outline, seoAnalysis, setSeoAnalysis, setSeoRecommendationsApplied]);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
@@ -387,6 +426,9 @@ export const useSEOManager = ({
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
try {
localStorage.setItem('blog_seo_recommendations_applied', 'true');
} catch {}
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content

View File

@@ -1,95 +1,37 @@
import React, { useState, useRef } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import React, { useRef } from 'react';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
}
/**
* Manual research form component that works independently of CopilotKit
* Extracted from ResearchAction.tsx for use when CopilotKit is unavailable
*/
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Refs for form inputs (uncontrolled, avoids typing issues)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
},
onComplete: (result) => {
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
onResearchComplete?.(result);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
}
});
const {
startResearch,
isSubmitting,
showProgressModal,
setShowProgressModal,
currentMessage,
currentStatus,
progressMessages,
error,
} = useResearchSubmit({ onResearchComplete });
const handleSubmit = async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) {
alert('Please enter keywords or a topic for research.');
return;
}
setIsSubmitting(true);
try {
const keywordList = keywords.includes(',')
? keywords.split(',').map(k => k.trim()).filter(Boolean)
: [keywords];
// Check cache first
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setIsSubmitting(false);
return;
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
await startResearch(keywords, blogLengthRef.current?.value || '1000');
} catch (error) {
console.error('Research failed:', error);
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsSubmitting(false);
}
};
@@ -170,9 +112,9 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
<ResearchProgressModal
open={showProgressModal}
title="Research in progress"
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
status={currentStatus}
messages={progressMessages}
error={error}
onClose={() => setShowProgressModal(false)}
/>
)}

View File

@@ -1,4 +1,8 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
export interface Phase {
id: string;
@@ -11,12 +15,12 @@ export interface Phase {
}
export interface PhaseActionHandlers {
onResearchAction?: () => void; // Show research form
onOutlineAction?: () => void; // Generate outline
onContentAction?: () => void; // Confirm outline + generate content
onSEOAction?: () => void; // Run SEO analysis
onApplySEORecommendations?: () => void; // Apply SEO recommendations
onPublishAction?: () => void; // Generate SEO metadata or publish
onResearchAction?: () => void;
onOutlineAction?: () => void;
onContentAction?: () => void;
onSEOAction?: () => void;
onApplySEORecommendations?: () => void;
onPublishAction?: () => void;
}
interface PhaseNavigationProps {
@@ -25,7 +29,6 @@ interface PhaseNavigationProps {
currentPhase: string;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
// State for determining which actions to show
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
@@ -36,6 +39,22 @@ interface PhaseNavigationProps {
hasSEOMetadata?: boolean;
}
const PHASE_TOOLTIPS: Record<string, string> = {
research: 'Research your topic and gather data from the web to create a well-informed blog post.',
outline: 'Create and refine your blog outline with AI-generated structure and key talking points.',
content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.',
seo: 'Optimize your blog for search engines with AI-powered SEO analysis, recommendations, and metadata.',
publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.',
};
const PHASE_ACTIONS: Record<string, string> = {
research: 'Enter keywords to research your topic',
outline: 'Create your blog outline to structure your content',
content: 'Generate and refine your blog content',
seo: 'Optimize your blog for search engines',
publish: 'Publish or export your finished blog',
};
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
onPhaseClick,
@@ -51,32 +70,22 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
seoRecommendationsApplied = false,
hasSEOMetadata = false,
}) => {
// Phase Navigation: Default interface for blog writing workflow
// - Phase buttons are always clickable and functional (for both CopilotKit and manual flows)
// - Action buttons (▶) only appear when CopilotKit is unavailable (manual fallback)
// - When CopilotKit is available, users can use either phase buttons or CopilotKit suggestions
// Determine which action to show for each phase when CopilotKit is unavailable
const totalPhases = phases.length;
const completedCount = phases.filter(p => p.completed).length;
const completionPct = totalPhases > 0 ? Math.round((completedCount / totalPhases) * 100) : 0;
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
// Show action buttons for both CopilotKit and manual flows (dual mode)
// Users can use either CopilotKit suggestions or phase navigation buttons
if (!actionHandlers) {
return { label: '', handler: null };
}
switch (phaseId) {
case 'research':
// Always show "Start Research" button when on research phase and no research exists yet
// This allows users to manually trigger research form
// If research already exists, don't show the button (user can click the phase button to view)
if (!hasResearch) {
return { label: 'Start Research', handler: actionHandlers.onResearchAction || null };
}
break;
case 'outline':
// Show "Create Outline" if research exists and outline is not yet confirmed
// This ensures users can create/regenerate outline after research, even if cached one exists
// Once outline is confirmed, we hide the button to avoid confusion during content generation
if (hasResearch && !outlineConfirmed) {
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
}
@@ -87,343 +96,329 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
break;
case 'seo':
// Priority order matching CopilotKit suggestions:
// 1. No SEO analysis yet - Run SEO Analysis
// Note: We check hasContent (sections exist) - contentConfirmed is checked but not strictly required
// This allows users to run SEO analysis even if contentConfirmed hasn't been explicitly set
if (hasContent && !hasSEOAnalysis) {
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
}
// 2. SEO analysis exists but recommendations not applied - Apply SEO Recommendations
if (hasSEOAnalysis && !seoRecommendationsApplied) {
return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null };
}
// 3. SEO analysis exists and recommendations applied but no metadata - Generate SEO Metadata
if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) {
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
}
break;
case 'publish':
// Only show if SEO metadata exists (ready to publish)
if (hasSEOAnalysis && seoRecommendationsApplied && hasSEOMetadata) {
return { label: 'Ready to Publish', handler: null }; // Publish handled separately
return { label: 'Ready to Publish', handler: null };
}
break;
}
return { label: '', handler: null };
};
const activePhase = phases.find(p => p.current);
const infoText = (() => {
if (!activePhase || !activePhase.id) {
if (completedCount === 0) return '📍 Start with Research to begin';
const next = phases.find(p => !p.completed && !p.disabled);
return next ? `👉 Next: ${next.name}` : '✅ All phases complete!';
}
const next = phases.find(p => !p.completed && !p.disabled);
if (activePhase.completed && !next) return '✅ All phases complete!';
if (activePhase.completed) return `${activePhase.name} done — Next: ${next!.name}`;
return `📍 ${activePhase.name}: ${PHASE_ACTIONS[activePhase.id] || 'Complete this phase'}`;
})();
return (
<>
<style>{`
/* Enterprise Phase Navigation Styles */
.phase-nav-container {
display: flex;
gap: 10px;
alignItems: center;
padding: 12px 0;
flexWrap: wrap;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.phase-chip {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 24px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
letter-spacing: 0.01em;
}
.phase-chip::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.phase-chip:hover::before {
left: 100%;
}
/* Current Phase - Active Gradient */
.phase-chip.current {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
transform: translateY(-2px) scale(1.02);
}
.phase-chip.current:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
.phase-chip.current:active {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Completed Phase - Success Gradient */
.phase-chip.completed {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: 0 3px 12px rgba(16, 185, 129, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-chip.completed:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Pending Phase - Subtle Gradient */
.phase-chip.pending {
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
color: #4b5563;
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.1);
}
.phase-chip.pending:hover {
background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%);
transform: translateY(-2px) scale(1.02);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
}
/* Disabled Phase */
.phase-chip.disabled {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
color: #9ca3af;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
border: 1px solid #e5e7eb;
}
.phase-chip.disabled:hover {
transform: none;
box-shadow: none;
}
/* Phase Icon */
.phase-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: transform 0.3s ease;
}
.phase-chip.current .phase-icon,
.phase-chip.completed .phase-icon {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.phase-chip:hover:not(.disabled) .phase-icon {
transform: scale(1.1) rotate(5deg);
}
/* Checkmark for completed */
.phase-checkmark {
font-size: 14px;
margin-left: 4px;
animation: checkmarkPop 0.3s ease;
}
@keyframes checkmarkPop {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Action Button - Enterprise Style */
.phase-action-btn {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
border: none;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 1px 2px rgba(0, 0, 0, 0.1) inset;
}
.phase-action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
transition: left 0.5s ease;
}
.phase-action-btn:hover::before {
left: 100%;
}
.phase-action-btn:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 2px 4px rgba(0, 0, 0, 0.15) inset;
}
.phase-action-btn:active {
transform: translateY(0) scale(1.02);
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
.phase-action-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3),
0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-action-icon {
font-size: 12px;
transition: transform 0.3s ease;
}
.phase-action-btn:hover .phase-action-icon {
transform: translateX(2px);
@keyframes phaseActivePulse {
0%, 100% {
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35), 0 0 0 0 rgba(37, 99, 235, 0.25);
}
50% {
box-shadow: 0 2px 16px rgba(37, 99, 235, 0.55), 0 0 0 4px rgba(37, 99, 235, 0.1);
}
}
`}</style>
<div className="phase-nav-container">
<Box sx={{
display: 'flex',
gap: 0.75,
alignItems: 'center',
flexWrap: 'wrap',
}}>
{/* Dynamic phase info text */}
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.4,
borderRadius: '20px',
background: 'rgba(37, 99, 235, 0.06)',
border: '1px solid rgba(37, 99, 235, 0.12)',
fontSize: '0.75rem',
color: '#475569',
fontWeight: 500,
whiteSpace: 'nowrap',
maxWidth: { xs: '180px', sm: '300px', md: '400px' },
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 0,
}}>
{infoText}
</Box>
{/* Phase chips */}
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
// Show action button when:
// 1. CopilotKit is unavailable
// 2. Action handler exists
// 3. Phase is not disabled
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
// For research phase: always show button when on research phase (allows manual trigger)
// For outline phase: always show if research exists but no outline (like research phase)
// For SEO phase: always show if action handler exists (prerequisites are met)
const isResearchPhase = phase.id === 'research' && action.handler; // Always show if handler exists
// Outline phase: show action whenever research exists and action handler is available
// This allows users to create/regenerate outline after research, even if cached one exists
const isResearchPhase = phase.id === 'research' && action.handler;
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
// SEO phase: show action whenever prerequisites are met (action handler exists)
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
const isSEOPhase = phase.id === 'seo' && action.handler;
// Debug logging for SEO phase (temporary - for troubleshooting)
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
console.log('[PhaseNavigation] SEO phase debug:', {
phaseId: phase.id,
isCurrent,
isCompleted,
isDisabled,
hasContent,
contentConfirmed,
hasSEOAnalysis,
seoRecommendationsApplied,
hasSEOMetadata,
actionLabel: action.label,
actionHandler: !!action.handler,
copilotKitAvailable,
isSEOPhase,
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase
)
});
}
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
// DUAL MODE: Show action buttons even when CopilotKit is available (users can use either method)
// For research phase: show action button when on research phase and no research exists yet (to start research)
const showAction = action.handler && (
(isCurrent && phase.id === 'research' && !hasResearch) || // Show "Start Research" when on research phase with no research
(isCurrent && phase.id !== 'research') || // For other phases, show action when current
(!isCompleted && !isDisabled) ||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase)) // Show for outline/SEO when appropriate
/* Phase state derivation:
- Active: phase is current AND not yet completed (user needs to work on it)
- Done: phase is completed (show green regardless of whether it's current)
- Pending: not current, not completed, not disabled */
const isActive = isCurrent && !isCompleted;
const isDone = isCompleted;
const isPending = !isCurrent && !isCompleted && !isDisabled;
/* Chip click: use action handler when available (same as action button),
fall back to navigation for viewing completed/disabled phases */
const handleChipClick = () => {
if (isDisabled) return;
if (action.handler) {
action.handler();
} else {
onPhaseClick(phase.id);
}
};
/* Show action button only when phase is NOT completed.
Research action: only on landing page (not current), to invite start.
Other phase actions: show when current, pending, or next-actionable. */
const showAction = action.handler && !isDone && (
(!isCurrent && phase.id === 'research' && !hasResearch) ||
(isCurrent && phase.id !== 'research') ||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase))
);
// Determine chip class
const chipClass = [
'phase-chip',
isCurrent ? 'current' : '',
isCompleted && !isCurrent ? 'completed' : '',
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
isDisabled ? 'disabled' : ''
].filter(Boolean).join(' ');
const iconOnly = isDone && !isCurrent;
const chipSx = {
display: 'flex',
alignItems: 'center',
gap: 0.5,
borderRadius: '20px',
border: 'none',
fontWeight: 600,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'clip',
/* Disabled phase: muted */
...(isDisabled && {
px: 1.25,
py: 0.5,
fontSize: '0.8125rem',
background: '#f1f5f9',
color: '#94a3b8',
border: '1px solid #e2e8f0',
opacity: 0.5,
}),
/* Done phase: green, collapsed to icon if not current */
...(isDone && !isDisabled && {
px: iconOnly ? 0.5 : 1.5,
py: 0.5,
fontSize: '0.8125rem',
justifyContent: iconOnly ? 'center' : 'flex-start',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: '#fff',
boxShadow: '0 2px 6px rgba(16, 185, 129, 0.25)',
maxWidth: iconOnly ? '36px' : 'none',
opacity: iconOnly ? 0.85 : 1,
'&:hover': {
maxWidth: iconOnly ? '160px' : 'none',
px: iconOnly ? 1.5 : 1.5,
opacity: 1,
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.35)',
},
}),
/* Active phase (current but not done): larger, pulse glow */
...(isActive && !isDisabled && {
px: 2,
py: 0.75,
fontSize: '0.875rem',
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
color: '#fff',
boxShadow: '0 2px 8px rgba(37, 99, 235, 0.35), inset 0 0 0 1px rgba(255,255,255,0.15)',
animation: 'phaseActivePulse 2s ease-in-out infinite',
'&:hover': {
transform: 'translateY(-1px) scale(1.03)',
boxShadow: '0 4px 16px rgba(37, 99, 235, 0.5), inset 0 0 0 1px rgba(255,255,255,0.2)',
},
'&:active': {
transform: 'translateY(0) scale(1.01)',
},
}),
/* Pending phase: compact, subtle */
...(isPending && {
px: 1.25,
py: 0.5,
fontSize: '0.8125rem',
background: 'rgba(37, 99, 235, 0.08)',
color: '#475569',
border: '1px solid rgba(37, 99, 235, 0.15)',
'&:hover': {
transform: 'translateY(-1px)',
background: 'rgba(37, 99, 235, 0.12)',
boxShadow: '0 3px 8px rgba(37, 99, 235, 0.15)',
},
}),
};
const actionBtnSx = {
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.4,
borderRadius: '16px',
border: 'none',
fontSize: '0.75rem',
fontWeight: 700,
cursor: 'pointer',
transition: 'all 0.2s ease',
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
color: '#fff',
boxShadow: '0 2px 6px rgba(37, 99, 235, 0.3)',
textTransform: 'uppercase',
letterSpacing: '0.03em',
whiteSpace: 'nowrap',
'&:hover': {
transform: 'translateY(-1px) scale(1.03)',
boxShadow: '0 4px 12px rgba(37, 99, 235, 0.4)',
},
'&:active': {
transform: 'translateY(0) scale(1.01)',
},
};
const iconSx = {
fontSize: '14px',
lineHeight: 1,
flexShrink: 0,
};
return (
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
className={chipClass}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
<Box key={phase.id} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip
title={
<Box>
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>{phase.name}</Box>
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
{isDisabled
? `Complete the previous phase first to unlock ${phase.name}.`
: (PHASE_TOOLTIPS[phase.id] || phase.description)}
</Box>
</Box>
}
arrow
placement="top"
enterDelay={300}
leaveDelay={100}
>
<span className="phase-icon">
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span className="phase-checkmark">
</span>
)}
</button>
{showAction && (
<button
onClick={(e) => {
e.stopPropagation();
action.handler?.();
}}
className="phase-action-btn"
title={`${action.label}`}
<Box
component="button"
onClick={handleChipClick}
sx={chipSx}
>
<span className="phase-action-icon"></span>
<span>{action.label}</span>
</button>
<Box component="span" sx={iconSx}>{phase.icon}</Box>
<Box component="span" sx={{ flexShrink: 0 }}>{phase.name}</Box>
{isDone && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}></Box>
)}
</Box>
</Tooltip>
{showAction && (
<Tooltip
title={`${action.label}`}
arrow
placement="top"
>
<Box
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
action.handler?.();
}}
sx={actionBtnSx}
>
<Box component="span" sx={{ fontSize: '10px' }}></Box>
<Box component="span">{action.label}</Box>
</Box>
</Tooltip>
)}
</div>
</Box>
);
})}
</div>
{/* Circular progress indicator */}
{totalPhases > 0 && (
<Tooltip
title={`${completedCount} of ${totalPhases} phases complete (${completionPct}%)`}
arrow
placement="top"
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
ml: 0.5,
position: 'relative',
cursor: 'pointer',
}}>
<CircularProgress
variant="determinate"
value={completionPct}
size={26}
thickness={3}
sx={{
color: completionPct === 100 ? '#10b981' : '#2563eb',
}}
/>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
fontWeight: 700,
color: '#64748b',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
{completionPct}
</Typography>
</Box>
</Tooltip>
)}
</Box>
</>
);
};

View File

@@ -12,6 +12,26 @@ interface PublisherProps {
seoMetadata: BlogSEOMetadataResponse | null;
}
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);
}
};
const useCopilotActionTyped = useCopilotAction as any;
interface WixConnectionStatus {
@@ -230,7 +250,15 @@ export const Publisher: React.FC<PublisherProps> = ({
}
// We have a valid access token, proceed with publishing
return await publishToWix(md, seoMetadata, tokenResult.accessToken);
const wixResult = await publishToWix(md, seoMetadata, tokenResult.accessToken);
if (wixResult.success) {
saveCompleteBlogAsset(
seoMetadata?.seo_title || 'Blog Post',
md,
seoMetadata
);
}
return wixResult;
} else if (platform === 'wordpress') {
// WordPress publishing
if (!seoMetadata) {
@@ -284,6 +312,7 @@ export const Publisher: React.FC<PublisherProps> = ({
const result = await wordpressAPI.publishContent(publishRequest);
if (result.success) {
saveCompleteBlogAsset(title, md, seoMetadata);
return {
success: true,
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useRef, useEffect } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useBlogWriterResearchPolling } from '../../hooks/usePolling';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
const useCopilotActionTyped = useCopilotAction as any;
@@ -13,111 +12,50 @@ interface ResearchActionProps {
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [forceUpdate, setForceUpdate] = useState<number>(0);
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
// Track if we've navigated to research phase for this form display
const hasNavigatedRef = useRef<boolean>(false);
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
console.info('[ResearchAction] ✅ Research completed (onComplete callback)', {
hasResult: !!result,
resultKeys: result ? Object.keys(result) : [],
status: polling.currentStatus
});
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
// Reset navigation tracking when research completes
hasNavigatedRef.current = false;
// Call parent callback first
onResearchComplete?.(result);
// Close modal immediately when research completes
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
}
});
const {
startResearch,
isSubmitting,
showProgressModal,
setShowProgressModal,
currentMessage,
currentStatus,
progressMessages,
error,
isPolling,
result,
} = useResearchSubmit({ onResearchComplete, navigateToPhase });
// Set of statuses that indicate successful completion
// Close modal when research completes (status becomes a completed state or polling stops with a result)
const COMPLETED_STATUSES = React.useMemo(
() => new Set(['completed', 'success', 'succeeded', 'finished']),
[]
);
// Close modal when research completes (status becomes a completed state or polling stops with a result)
useEffect(() => {
const normalizedStatus = (polling.currentStatus || '').toLowerCase();
const normalizedStatus = (currentStatus || '').toLowerCase();
const isCompleted = COMPLETED_STATUSES.has(normalizedStatus);
// Check if we have a result (indicates completion even if status isn't updated yet)
const hasResult = !!polling.result;
// Check if polling stopped and we have a result, or status indicates completion
const hasResult = !!result;
const shouldClose = showProgressModal && (
isCompleted ||
(hasResult && normalizedStatus !== 'failed') ||
(!polling.isPolling && hasResult && normalizedStatus !== 'failed')
(!isPolling && hasResult && normalizedStatus !== 'failed')
);
if (shouldClose) {
console.info('[ResearchAction] Closing modal - research completed', {
status: polling.currentStatus,
isPolling: polling.isPolling,
hasResult: hasResult,
normalizedStatus: normalizedStatus,
isCompleted: isCompleted
});
// Close modal immediately when research completes
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}
}, [
COMPLETED_STATUSES,
polling.currentStatus,
polling.isPolling,
polling.result,
showProgressModal
]);
}, [COMPLETED_STATUSES, currentStatus, isPolling, result, showProgressModal, setShowProgressModal]);
useCopilotActionTyped({
name: 'showResearchForm',
description: 'Show keyword input form for blog research',
parameters: [],
handler: async () => {
// Navigate to research phase when research form is shown
// Reset navigation tracking so form render can navigate again if needed
hasNavigatedRef.current = false;
// Navigate immediately when handler is called
if (navigateToPhase) {
navigateToPhase('research');
}
@@ -128,64 +66,34 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
};
},
render: ({ status }: any) => {
try {
const _ = forceUpdate;
// Safely access polling state with defaults - handle case where polling might not be initialized
let currentStatus = 'idle';
let progressMessages: Array<{ timestamp: string; message: string }> = [];
try {
if (polling) {
currentStatus = polling.currentStatus || 'idle';
progressMessages = polling.progressMessages || [];
const isShowingForm = currentStatus !== 'completed' &&
currentStatus !== 'in_progress' &&
currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
}
} catch (pollingError) {
console.warn('[ResearchAction] Error accessing polling state in render:', pollingError);
// Use defaults already set above
}
// Navigate to research phase when form is rendered (if not already navigated and form is shown)
// This ensures phase navigation updates when CopilotKit shows the research form
// Only navigate when showing the form (not progress or completion states)
const isShowingForm = currentStatus !== 'completed' &&
currentStatus !== 'in_progress' &&
currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
// Use setTimeout to avoid calling during render
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
}
}, 0);
}
if (currentStatus === 'completed' && progressMessages.length > 0) {
const latestMessage = progressMessages[progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (currentStatus === 'in_progress' || currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
} catch (renderError) {
console.error('[ResearchAction] Error in render function:', renderError);
// Return a safe fallback UI
}, 0);
}
if (currentStatus === 'completed' && progressMessages.length > 0) {
const latestMessage = progressMessages[progressMessages.length - 1];
return (
<div style={{ padding: '16px', backgroundColor: '#f8f9fa', borderRadius: '8px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}>🔍 Research form is loading...</p>
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}> Research completed successfully!</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
</div>
);
}
if (currentStatus === 'in_progress' || currentStatus === 'running') {
return (
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
</div>
);
}
@@ -204,7 +112,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
/>
</div>
@@ -214,7 +123,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
@@ -225,38 +135,20 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setForceUpdate(prev => prev + 1);
return;
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}`);
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
// Navigate to research phase when research starts
navigateToPhase?.('research');
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
setForceUpdate(prev => prev + 1);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
}}
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' }}
>
🚀 Start Research
{isSubmitting ? ' Starting Research...' : '🚀 Start Research'}
</button>
</div>
</div>
@@ -264,7 +156,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
// Additional action to catch the specific suggestion message
// CopilotKit chat action: research topic with keywords
useCopilotActionTyped({
name: 'researchTopic',
description: 'Research topic with keywords and persona context using Google Search grounding',
@@ -276,25 +168,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
],
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
try {
const trimmed = keywords.trim();
if (!trimmed) {
return "Please provide keywords or a topic for research.";
}
const keywordList = trimmed.includes(',')
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
: [trimmed];
// Navigate to research phase when research starts
navigateToPhase?.('research');
const payload: BlogResearchRequest = {
keywords: keywordList,
industry,
target_audience,
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
await startResearch(keywords, blogLength, industry, target_audience);
return "Starting research with your provided keywords.";
} catch (error) {
console.error('Failed to start research:', error);
@@ -303,21 +177,16 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
return (
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => {
console.info('[ResearchAction] Modal closed manually');
setShowProgressModal(false);
setCurrentTaskId(null);
}}
title="Research in progress"
status={currentStatus}
messages={progressMessages}
error={error}
onClose={() => setShowProgressModal(false)}
/>
)}
</>

View File

@@ -27,6 +27,7 @@ import {
Avatar,
CircularProgress
} from '@mui/material';
import { hashContent, getSeoCacheKey } from '../../utils/contentHash';
import { apiClient, triggerSubscriptionError } from '../../api/client';
import {
CheckCircle,
@@ -145,24 +146,7 @@ interface SEOAnalysisModalProps {
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
// Simple content hashing helper (SHA-256)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getSeoCacheKey(contentHash: string, title?: string) {
return `seo_cache:${contentHash}:${title || ''}`;
}
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
isOpen,

View File

@@ -1,20 +1,20 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
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 {
AutoAwesome as AutoAwesomeIcon,
MoreHoriz as MoreHorizIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
import EditorSidebar from './EditorSidebar';
import HoverMenu from './HoverMenu';
// Helper to create a consistent theme
const theme = createTheme({
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
palette: {
primary: {
main: '#4f46e5',
},
primary: { main: '#4f46e5' },
},
});
@@ -48,16 +48,26 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
sectionImages = {}
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('Click "Generate Introduction" to create a compelling opening for your blog post based on your content and research.');
const [introduction, setIntroduction] = useState('');
const [sections, setSections] = useState<any[]>([]);
// const [isTitleLoading, setIsTitleLoading] = useState(false); // Unused state
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
const [showIntroductionModal, setShowIntroductionModal] = useState(false);
const [generatedIntroductions, setGeneratedIntroductions] = useState<string[]>([]);
const [editingTitle, setEditingTitle] = useState(false);
const [editingIntro, setEditingIntro] = useState(false);
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const introInputRef = useRef<HTMLInputElement>(null);
const totalWords = useMemo(() =>
sections.reduce((sum, s) => sum + (s.content?.split(/\s+/).filter(Boolean).length || 0), 0),
[sections]
);
const readingTime = useMemo(() => Math.max(1, Math.ceil(totalWords / 200)), [totalWords]);
// Initialize sections from outline or use parent sections
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
@@ -78,53 +88,39 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
}, [outline, parentSections]);
// Update sections when parentSections content changes (e.g., after SEO recommendations are applied)
// This effect specifically watches for content changes in parentSections and updates the corresponding sections
// Use a ref to track the previous parentSections content to detect actual content changes
const prevParentSectionsRef = useRef<string>('');
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
// Create a stringified version of parentSections for comparison
const parentSectionsString = JSON.stringify(parentSections);
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
// Update if content changed OR continuityRefresh changed (forced refresh)
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
return; // No changes detected
return;
}
prevParentSectionsRef.current = parentSectionsString;
prevContinuityRefreshRef.current = continuityRefresh;
setSections(prevSections => {
// Update sections with new content from parentSections
const updatedSections = prevSections.map(section => {
// Try multiple ID formats to match sections (string, number, or stringified number)
const sectionIdStr = String(section.id);
const parentContent = parentSections[section.id] ||
parentSections[sectionIdStr] ||
parentSections[Number(section.id)];
// Update if parent has content for this section ID and it's different
if (parentContent !== undefined && parentContent !== section.content) {
console.log(`[BlogEditor] Updating section ${section.id} with new content (length: ${parentContent.length})`);
return {
...section,
content: parentContent
};
return { ...section, content: parentContent };
}
return section;
});
// Check if any sections were actually updated
const hasUpdates = updatedSections.some((section, index) =>
section.content !== prevSections[index]?.content
);
// Notify parent component of content update if changes were made
if (onContentUpdate && hasUpdates) {
onContentUpdate(updatedSections);
}
@@ -133,17 +129,41 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
});
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
setBlogTitle(initialTitle);
}
}, [initialTitle]);
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
useEffect(() => {
if (editingIntro && introInputRef.current) {
introInputRef.current.focus();
introInputRef.current.select();
}
}, [editingIntro]);
const handleSuggestTitle = useCallback(() => {
console.log('Available titles:', { researchTitles, aiGeneratedTitles, titleOptions });
setShowTitleModal(true);
}, [researchTitles, aiGeneratedTitles, titleOptions]);
}, []);
const handleTitleAction = useCallback((action: string) => {
switch (action) {
case 'generate-titles':
case 'research-titles':
setShowTitleModal(true);
break;
case 'seo-optimize':
case 'ab-test':
break;
}
}, []);
const handleTitleSelect = useCallback((selectedTitle: string) => {
setBlogTitle(selectedTitle);
@@ -151,9 +171,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}, []);
const handleGenerateIntroductions = useCallback(async () => {
if (!research || !outline.length || isIntroductionLoading) {
return;
}
if (!research || !outline.length || isIntroductionLoading) return;
setIsIntroductionLoading(true);
try {
@@ -161,7 +179,6 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const primaryKeywords = keywordAnalysis.primary || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Build sections_content from current sections
const sectionsContent: Record<string, string> = {};
sections.forEach(section => {
if (section.content) {
@@ -184,7 +201,6 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
} catch (error) {
console.error('Failed to generate introductions:', error);
alert('Failed to generate introductions. Please try again.');
} finally {
setIsIntroductionLoading(false);
}
@@ -198,75 +214,107 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
if (newSet.has(sectionId)) newSet.delete(sectionId);
else newSet.add(sectionId);
return newSet;
});
}, []);
// Main Render - Exactly like your example
return (
<ThemeProvider theme={theme}>
<div className="bg-gray-50 min-h-screen font-sans">
<main className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="w-full max-w-4xl mx-auto">
<Paper elevation={0} className="bg-white p-8 md:p-12 rounded-xl border border-gray-200/80 w-full">
<div className="mb-8 pb-6 border-b">
<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">
<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="flex items-start gap-2 group">
<h1
className="flex-1 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.3'
}}
onClick={() => {
const newTitle = prompt('Edit blog title:', blogTitle);
if (newTitle !== null) {
setBlogTitle(newTitle);
}
}}
title="Click to edit title"
>
{blogTitle}
</h1>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 mt-1">
<Tooltip title="✨ ALwrity it">
{/* isTitleLoading is currently unused but kept for future implementation */}
{editingTitle ? (
<TextField
inputRef={titleInputRef}
fullWidth
variant="standard"
value={blogTitle}
onChange={(e) => setBlogTitle(e.target.value)}
onBlur={() => setEditingTitle(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') setEditingTitle(false);
if (e.key === 'Escape') setEditingTitle(false);
}}
InputProps={{
disableUnderline: true,
className: 'text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight truncate min-w-0',
}}
/>
) : (
<h1
className="flex-1 min-w-0 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150 truncate"
onClick={() => setEditingTitle(true)}
>
{blogTitle}
</h1>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mt-1 shrink-0 flex items-center gap-1">
<Tooltip title="Title actions">
<IconButton size="small" onClick={(e) => setTitleMenuAnchor(e.currentTarget)}>
<MoreHorizIcon className="text-gray-400" fontSize="small"/>
</IconButton>
</Tooltip>
<Tooltip title="Choose from AI titles">
<IconButton onClick={handleSuggestTitle} size="small">
<AutoAwesomeIcon className="text-purple-500" fontSize="small"/>
</IconButton>
</Tooltip>
</div>
<HoverMenu
anchorEl={titleMenuAnchor}
open={Boolean(titleMenuAnchor)}
onClose={() => setTitleMenuAnchor(null)}
type="title"
onAction={handleTitleAction}
/>
</div>
<div className="mt-3 group/intro">
{/* Introduction */}
<div className="mt-4 group/intro">
<div className="flex items-start gap-2">
<p
className="flex-1 text-gray-600 text-sm leading-relaxed cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
onClick={() => {
const newIntro = prompt('Edit introduction:', introduction);
if (newIntro !== null && newIntro.trim()) {
setIntroduction(newIntro.trim());
}
}}
title="Click to edit introduction"
>
{introduction}
</p>
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-300">
<Tooltip title="✨ Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
{editingIntro ? (
<TextField
inputRef={introInputRef}
fullWidth
variant="standard"
multiline
minRows={2}
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
onBlur={() => setEditingIntro(false)}
placeholder="Write an engaging introduction..."
InputProps={{
disableUnderline: true,
className: 'text-base text-gray-600 leading-relaxed',
}}
/>
) : (
<p
className={`flex-1 text-base leading-relaxed cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150 ${
introduction ? 'text-gray-600' : 'text-gray-400'
}`}
onClick={() => setEditingIntro(true)}
>
{introduction || 'Click to write your introduction...'}
</p>
)}
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-200 shrink-0">
<Tooltip title="Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
size="small"
>
{isIntroductionLoading ? (
<CircularProgress size={20} />
<CircularProgress size={18} />
) : (
<AutoAwesomeIcon className="text-blue-500" fontSize="small"/>
)}
@@ -275,11 +323,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
</div>
</div>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
{/* Sections */}
<div className="space-y-1">
{sections.map((section, index) => {
// Robust image mapping: prefer outline index id (order is consistent across phases)
const imageIdByIndex = outline[index]?.id;
const outlineSection = outline.find(s => (s.id === section.id) || (s.heading === section.title));
const imageId = imageIdByIndex || outlineSection?.id || section.id;
@@ -298,17 +346,46 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
);
})}
</div>
</Paper>
{/* 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)}%` }}
/>
</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>
</div>
</div>
</Paper>
</div>
{/* Sidebar */}
<div className="hidden lg:block w-72 shrink-0">
<div className="sticky top-6">
<EditorSidebar sections={sections} totalWords={totalWords} />
</div>
</div>
</div>
</main>
</div>
{/* Title Selection Modal */}
<Dialog
open={showTitleModal}
onClose={() => setShowTitleModal(false)}
maxWidth="md"
fullWidth
>
<Dialog open={showTitleModal} onClose={() => setShowTitleModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Title
@@ -316,11 +393,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{/* Research Titles */}
{researchTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'primary.main' }}>
📊 Research-Based Titles
Research-Based Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{researchTitles.map((title, index) => (
@@ -329,17 +405,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'primary.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -347,12 +413,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{/* AI Generated Titles */}
{aiGeneratedTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'secondary.main' }}>
🤖 AI Generated Titles
AI Generated Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{aiGeneratedTitles.map((title, index) => (
@@ -361,17 +425,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'secondary.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -379,12 +433,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{/* Title Options */}
{titleOptions.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'success.main' }}>
Additional Options
Additional Options
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{titleOptions.map((title, index) => (
@@ -393,17 +445,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'success.light',
color: 'white',
}
}}
sx={{ justifyContent: 'flex-start', textAlign: 'left', textTransform: 'none', py: 1.5, px: 2 }}
>
{title}
</Button>
@@ -411,82 +453,48 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</Box>
)}
{researchTitles.length === 0 && aiGeneratedTitles.length === 0 && titleOptions.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No title options available. Please generate an outline first.
</Typography>
)}
{/* Debug info */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
Debug: Research titles: {researchTitles.length}, AI titles: {aiGeneratedTitles.length}, Options: {titleOptions.length}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowTitleModal(false)}>
Cancel
</Button>
<Button onClick={() => setShowTitleModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Introduction Selection Modal */}
<Dialog
open={showIntroductionModal}
onClose={() => setShowIntroductionModal(false)}
maxWidth="md"
fullWidth
>
<Dialog open={showIntroductionModal} onClose={() => setShowIntroductionModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Introduction
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Select one of the AI-generated introductions below. Each offers a different approach to hooking your readers.
Select one of the AI-generated introductions below.
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{generatedIntroductions.map((intro, index) => (
<Box
key={index}
sx={{
mb: 3,
p: 2,
<Box
key={index}
sx={{
mb: 3, p: 2,
border: '1px solid',
borderColor: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main',
borderRadius: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
cursor: 'pointer',
transition: 'all 0.2s ease'
transition: 'all 0.2s ease',
'&:hover': { backgroundColor: 'action.hover' },
}}
onClick={() => handleIntroductionSelect(intro)}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 1,
color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main'
}}
>
{index === 0 ? '📌 Option 1: Problem-Focused' : index === 1 ? '✨ Option 2: Benefit-Focused' : '📊 Option 3: Story/Statistic-Focused'}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 1, color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main' }}>
{index === 0 ? 'Problem-Focused' : index === 1 ? 'Benefit-Focused' : 'Story/Statistic-Focused'}
</Typography>
<Typography
variant="body1"
sx={{
color: 'text.primary',
lineHeight: 1.7,
whiteSpace: 'pre-wrap'
}}
>
<Typography variant="body1" sx={{ color: 'text.primary', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{intro}
</Typography>
</Box>
@@ -494,9 +502,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIntroductionModal(false)}>
Cancel
</Button>
<Button onClick={() => setShowIntroductionModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
</div>
@@ -504,4 +510,4 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
);
};
export default BlogEditor;
export default BlogEditor;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import {
Paper,
IconButton,
@@ -6,17 +6,7 @@ import {
TextField,
Tooltip,
CircularProgress,
Divider,
Button,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemText
Divider
} from '@mui/material';
import {
Edit as EditIcon,
@@ -24,12 +14,12 @@ import {
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
MoreHoriz as MoreHorizIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
import { ContinuityBadge } from '../ContinuityBadge';
import HoverMenu from './HoverMenu';
import { blogWriterApi } from '../../../services/blogWriterApi';
interface BlogSectionProps {
@@ -57,7 +47,6 @@ const BlogSection: React.FC<BlogSectionProps> = ({
id,
title,
content: initialContent,
wordCount,
sources,
outlineData,
onContentUpdate,
@@ -80,205 +69,142 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const [toolResult, setToolResult] = useState<any>(null);
const [toolDialogOpen, setToolDialogOpen] = useState(false);
// Initialize assistive writing handler
const wordCount_ = useMemo(() => content.split(/\s+/).filter(Boolean).length, [content]);
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
// For smart suggestions, newText is already the complete updated content with insertion
// For other edits (like text selection improvements), we need to replace originalText with newText
let updatedContent: string;
if (editType === 'smart-suggestion') {
// newText already contains the full content with suggestion inserted
updatedContent = newText;
} else {
// For other edits, replace the selected text
const currentContent = textarea.value;
updatedContent = currentContent.replace(originalText, newText);
updatedContent = textarea.value.replace(originalText, newText);
}
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
setContent(updatedContent);
// Update parent state
if (onContentUpdate) {
onContentUpdate([{ id, content: updatedContent }]);
}
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
// For other edits, we may need to handle cursor positioning here if needed
if (onContentUpdate) onContentUpdate([{ id, content: updatedContent }]);
}
}
);
// Format content helper - ensures proper paragraph breaks
const formatContent = (rawContent: string) => {
if (!rawContent) return rawContent;
// Ensure double line breaks between paragraphs
// Replace single line breaks with double line breaks if they're not already double
let formatted = rawContent
.replace(/\n{3,}/g, '\n\n') // Replace 3+ line breaks with double
.replace(/\n(?!\n)/g, '\n\n') // Replace single line breaks with double
.trim();
return formatted;
return rawContent.replace(/\n{3,}/g, '\n\n').replace(/\n(?!\n)/g, '\n\n').trim();
};
// Sync content when initialContent changes (e.g., from AI generation)
useEffect(() => {
if (initialContent !== content) {
const formattedContent = formatContent(initialContent);
setContent(formattedContent);
setContent(formatContent(initialContent));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialContent]);
const handleContentChange = (e: any) => {
const newContent = e.target.value;
console.log('🔍 [BlogSection] handleContentChange called, content length:', newContent.length);
setContent(newContent);
// Trigger smart typing assist
assistiveWriting.handleTypingChange(newContent);
};
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const openToolsMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setToolsAnchorEl(event.currentTarget);
};
const closeToolsMenu = () => {
setToolsAnchorEl(null);
};
const closeToolDialog = () => {
setToolDialogOpen(false);
setToolLoading(false);
};
const runSectionTool = async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
closeToolsMenu();
const runSectionTool = useCallback(async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
setActiveTool(tool);
setToolResult(null);
setToolLoading(true);
setToolDialogOpen(true);
try {
let res;
if (tool === 'originality') {
const res = await blogWriterApi.sectionOriginalityTools({
section_id: String(id),
title: sectionTitle,
content
res = await blogWriterApi.sectionOriginalityTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'links') {
res = await blogWriterApi.sectionInternalLinkTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'fact') {
res = await blogWriterApi.sectionFactCheckTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'optimize') {
res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id), title: sectionTitle, content,
keywords: outlineData?.keywords || [], goal: 'readability'
});
setToolResult(res);
return;
}
if (tool === 'links') {
const res = await blogWriterApi.sectionInternalLinkTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'fact') {
const res = await blogWriterApi.sectionFactCheckTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'optimize') {
const res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id),
title: sectionTitle,
content,
keywords: outlineData?.keywords || [],
goal: 'readability'
});
setToolResult(res);
return;
}
if (tool === 'flow') {
const res = await blogWriterApi.analyzeFlowAdvanced({
} else if (tool === 'flow') {
res = await blogWriterApi.analyzeFlowAdvanced({
title: sectionTitle,
sections: [{ id: String(id), heading: sectionTitle, content }]
});
setToolResult(res);
return;
}
setToolResult(res);
} catch (error: any) {
setToolResult({ success: false, error: error?.message || 'Request failed' });
} finally {
setToolLoading(false);
}
};
}, [id, sectionTitle, content, outlineData]);
const applyOptimizedContent = () => {
const next = toolResult?.optimized_content;
if (!next) return;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
closeToolDialog();
};
const insertLinkSuggestion = (url: string) => {
if (!url) return;
const insertion = `\n\n[Related](${url})`;
const next = `${content || ''}${insertion}`;
const next = `${content || ''}\n\n[Related](${url})`;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
};
const handleGenerateContent = async () => {
setIsGenerating(true);
try {
// This would call your AI service for content generation
await new Promise(resolve => setTimeout(resolve, 2000));
const generated = `This is AI-generated content for "${sectionTitle}" with engaging, well-structured paragraphs grounded in your research.`;
setContent(generated);
// Update parent state if needed
if (onContentUpdate) {
onContentUpdate([{ id, content: generated }]);
}
} catch (error) {
console.error('Failed to generate content:', error);
} finally {
setIsGenerating(false);
}
await new Promise(resolve => setTimeout(resolve, 2000));
setIsGenerating(false);
};
// HoverMenu action handler
const handleSectionAction = useCallback((action: string) => {
switch (action) {
case 'generate-content':
handleGenerateContent();
break;
case 'enhance-section':
runSectionTool('optimize');
break;
case 'fact-check':
runSectionTool('fact');
break;
case 'source-mapping':
runSectionTool('originality');
break;
case 'seo-analysis':
runSectionTool('flow');
break;
case 'add-subsection':
break;
case 'copy-section':
break;
case 'delete-section':
break;
default:
break;
}
}, []);
return (
<div
className="group relative mb-6"
className="group relative mb-8"
id={`section-${id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs font-medium text-gray-300 select-none">{id}.</span>
{isEditing ? (
<TextField
fullWidth
@@ -286,182 +212,117 @@ const BlogSection: React.FC<BlogSectionProps> = ({
value={sectionTitle}
onChange={(e) => setSectionTitle(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') setIsEditing(false); }}
autoFocus
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800' }}
InputProps={{ disableUnderline: true, className: 'text-xl md:text-2xl font-bold font-serif text-gray-800' }}
/>
) : (
<h2
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 cursor-pointer"
className="flex-1 text-xl md:text-2xl font-bold font-serif text-gray-800 cursor-text hover:text-indigo-600 transition-colors duration-150"
onClick={() => setIsEditing(true)}
>
{sectionTitle}
</h2>
)}
</div>
{/* Section Image Display */}
{sectionImage && (
<div style={{ marginBottom: '16px', marginTop: '8px' }}>
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
maxWidth: '100%',
backgroundColor: '#fff'
}}>
<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}`}
style={{
width: '100%',
height: 'auto',
display: 'block',
maxHeight: '400px',
objectFit: 'contain'
}}
className="w-full h-auto max-h-96 object-contain"
/>
</div>
</div>
)}
{isGenerating ? (
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg animate-pulse">
<span className="text-gray-500 font-medium">Generating content based on research...</span>
</div>
) : (
<div className="relative">
{/* Image Placeholder */}
{outlineData?.keywords && outlineData.keywords.length > 0 && (
<div className="absolute -right-4 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip title="Section image coming soon">
<IconButton size="small">
<img
src={`https://source.unsplash.com/random/800x600?${outlineData.keywords[0]}`}
alt=""
className="w-8 h-8 rounded object-cover"
/>
</IconButton>
</Tooltip>
</div>
)}
<TextField
multiline
fullWidth
variant="outlined"
placeholder="Start writing or use AI to generate content..."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
minRows={6}
InputProps={{
className: `font-serif text-lg leading-relaxed text-gray-700 p-0 border-none ${isFocused ? 'bg-white' : 'bg-transparent'} transition-colors duration-200`,
style: { lineHeight: '1.8' }
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root': { padding: 0 }
}}
/>
</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>
) : (
<div className="relative">
<TextField
multiline
fullWidth
variant="outlined"
placeholder="Start writing..."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelect={assistiveWriting.handleTextSelection}
inputRef={contentRef}
minRows={5}
InputProps={{
className: `font-serif text-base leading-relaxed text-gray-700 p-0 ${isFocused ? 'bg-white' : 'bg-transparent'}`,
style: { lineHeight: '1.8' }
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root': { padding: 0 },
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
/>
</div>
)}
{/* Outline Information Section */}
{/* Outline info */}
{outlineData && expandedSections.has(id) && (
<div className="mt-4">
<Paper elevation={0} sx={{ p: 2, bgcolor: '#f8f9fa', borderRadius: 2, mb: 2 }}>
<div className="flex flex-col gap-4">
{/* Key Points */}
{outlineData.keyPoints && outlineData.keyPoints.length > 0 && (
<div className="mt-3 mb-2">
<Paper elevation={0} sx={{ p: 3, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #f0f0f0' }}>
<div className="grid grid-cols-2 gap-4">
{outlineData.keyPoints?.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Key Points:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keyPoints.map((point: any, index: any) => (
<Chip
key={index}
label={point}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Key Points</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keyPoints.map((point: any, i: any) => (
<Chip key={i} label={point} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* Subheadings */}
{outlineData.subheadings && outlineData.subheadings.length > 0 && (
{outlineData.subheadings?.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Subheadings:</div>
<div className="flex flex-wrap gap-1">
{outlineData.subheadings.map((subheading: any, index: any) => (
<Chip
key={index}
label={subheading}
size="small"
variant="outlined"
color="secondary"
sx={{ fontSize: '0.75rem' }}
/>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Subheadings</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.subheadings.map((sub: any, i: any) => (
<Chip key={i} label={sub} size="small" variant="outlined" color="secondary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* Keywords */}
{outlineData.keywords && outlineData.keywords.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Keywords:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keywords.map((keyword: any, index: any) => (
<Chip
key={index}
label={keyword}
size="small"
variant="filled"
color="primary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Target Words */}
{outlineData.targetWords > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
Target Words: {outlineData.targetWords}
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Target words</div>
<div className="text-sm text-gray-700">{outlineData.targetWords}</div>
</div>
)}
{outlineData.keywords?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Keywords</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keywords.map((kw: any, i: any) => (
<Chip key={i} label={kw} size="small" variant="filled" color="primary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{/* References */}
{outlineData.references && outlineData.references.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
References ({outlineData.references.length}):
{outlineData.references?.length > 0 && (
<div className="col-span-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
References ({outlineData.references.length})
</div>
<div className="flex flex-wrap gap-1">
{outlineData.references.slice(0, 3).map((ref: any, index: any) => (
<Chip
key={index}
label={ref.title || `Source ${index + 1}`}
size="small"
variant="outlined"
color="info"
sx={{ fontSize: '0.75rem' }}
/>
<div className="flex flex-wrap gap-1.5">
{outlineData.references.slice(0, 3).map((ref: any, i: any) => (
<Chip key={i} label={ref.title || `Source ${i + 1}`} size="small" variant="outlined" color="info" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
{outlineData.references.length > 3 && (
<Chip
label={`+${outlineData.references.length - 3} more`}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
<Chip label={`+${outlineData.references.length - 3} more`} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
)}
</div>
</div>
@@ -471,180 +332,156 @@ const BlogSection: React.FC<BlogSectionProps> = ({
</div>
)}
<div className="absolute -bottom-4 right-0 flex items-center space-x-1" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.3s' }}>
<Chip label={`${content.split(' ').length} words`} size="small" variant="outlined" className="!text-gray-500" />
<Chip icon={<LinkIcon />} label={`${sources} sources`} size="small" variant="outlined" className="!text-gray-500" />
{outlineData && (
<Chip
icon={expandedSections.has(id) ? <ExpandLessIcon /> : <ExpandMoreIcon />}
label="Outline Info"
size="small"
variant="outlined"
clickable
onClick={() => toggleSectionExpansion(id)}
sx={{
fontSize: '0.75rem',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
}
}}
/>
)}
<Tooltip title="Generate Content">
<IconButton size="small" onClick={handleGenerateContent}>
<AutoAwesomeIcon fontSize="small" />
{/* Bottom toolbar */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">{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>
</>
)}
</div>
<div className="flex items-center gap-0.5" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s' }}>
{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>
</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>
</Tooltip>
{/* Flow Analysis Badge - Enabled when flow analysis results are available */}
<ContinuityBadge
sectionId={id}
refreshToken={refreshToken}
disabled={!flowAnalysisResults}
flowAnalysisResults={flowAnalysisResults}
/>
<Tooltip title="Section Tools">
<IconButton size="small" onClick={openToolsMenu}>
<InfoIcon fontSize="small" />
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<DeleteOutlineIcon sx={{ fontSize: 16, color: '#9ca3af' }} />
</IconButton>
</Tooltip>
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
</div>
</div>
<Menu
{/* HoverMenu for section-level actions */}
<HoverMenu
anchorEl={toolsAnchorEl}
open={Boolean(toolsAnchorEl)}
onClose={closeToolsMenu}
>
<MenuItem onClick={() => runSectionTool('originality')}>Originality Check</MenuItem>
<MenuItem onClick={() => runSectionTool('optimize')}>Optimize Section</MenuItem>
<MenuItem onClick={() => runSectionTool('fact')}>SIF Fact Check</MenuItem>
<MenuItem onClick={() => runSectionTool('links')}>Internal Link Suggestions</MenuItem>
<MenuItem onClick={() => runSectionTool('flow')}>Flow Analysis</MenuItem>
</Menu>
onClose={() => setToolsAnchorEl(null)}
type="section"
onAction={handleSectionAction}
context={{
sectionId: String(id),
hasContent: content.trim().length > 0,
sources,
wordCount: wordCount_,
}}
/>
<Dialog open={toolDialogOpen} onClose={closeToolDialog} fullWidth maxWidth="md">
<DialogTitle>
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</DialogTitle>
<DialogContent dividers>
{toolLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<CircularProgress size={18} />
<div>Working</div>
{/* Tool result dialog */}
{toolDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" onClick={closeToolDialog}>
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-800">
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</h3>
</div>
)}
{!toolLoading && toolResult?.error && (
<div style={{ color: '#b91c1c', fontWeight: 600 }}>{toolResult.error}</div>
)}
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.diff_summary && (
<div style={{ fontWeight: 600 }}>{toolResult.diff_summary}</div>
<div className="px-6 py-4 overflow-y-auto flex-1">
{toolLoading && (
<div className="flex items-center gap-3">
<CircularProgress size={18} />
<span className="text-sm text-gray-500">Working...</span>
</div>
)}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<List dense>
{toolResult.changes_made.map((c: string, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c} />
</ListItem>
))}
</List>
{!toolLoading && toolResult?.error && (
<div className="text-red-600 font-medium">{toolResult.error}</div>
)}
<TextField
multiline
minRows={10}
value={toolResult.optimized_content}
fullWidth
InputProps={{ readOnly: true }}
/>
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
<List>
{toolResult.suggestions.map((s: any, idx: number) => (
<ListItem key={idx} secondaryAction={
<Button size="small" onClick={() => insertLinkSuggestion(s.url)}>Insert</Button>
}>
<ListItemText
primary={s.url}
secondary={`confidence: ${(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}${s.reason ?? ''}`}
/>
</ListItem>
))}
</List>
) : (
<div>No suggestions yet. Make sure SIF index has your website content.</div>
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div className="space-y-3">
{toolResult?.diff_summary && <p className="font-medium">{toolResult.diff_summary}</p>}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{toolResult.changes_made.map((c: string, idx: number) => (
<li key={idx} className="text-sm text-gray-600">{c}</li>
))}
</ul>
)}
<TextField multiline minRows={10} value={toolResult.optimized_content} fullWidth InputProps={{ readOnly: true }} />
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div className="space-y-2">
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
toolResult.suggestions.map((s: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 truncate">{s.url}</p>
<p className="text-xs text-gray-500">confidence: {(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}</p>
</div>
<button onClick={() => insertLinkSuggestion(s.url)} className="text-sm text-indigo-600 hover:text-indigo-800 ml-3 shrink-0">Insert</button>
</div>
))
) : (
<p className="text-sm text-gray-500">No suggestions yet. Make sure SIF index has your website content.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div className="space-y-3">
{toolResult?.cannibalization && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<div className="space-y-2">
{toolResult.matches.map((m: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">{m.id ?? 'unknown'} ({(m.score ?? 0).toFixed?.(3) ?? m.score})</p>
{m.excerpt && <p className="text-xs text-gray-500 mt-1">{m.excerpt}</p>}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No close matches found.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div className="space-y-3">
{toolResult?.verification && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.verification, null, 2)}</pre>}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<div className="space-y-2">
{toolResult.citations.map((c: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm">{c.citation_text || c.title || c.source}</p>
<p className="text-xs text-gray-500">{c.source}</p>
</div>
))}
</div>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult, null, 2)}</pre>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.cannibalization && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>
)}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<List>
{toolResult.matches.map((m: any, idx: number) => (
<ListItem key={idx}>
<ListItemText
primary={`${m.id ?? 'unknown'} (${(m.score ?? 0).toFixed?.(3) ?? m.score})`}
secondary={m.excerpt}
/>
</ListItem>
))}
</List>
) : (
<div>No close matches found.</div>
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
<button onClick={closeToolDialog} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors">Close</button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<button onClick={applyOptimizedContent} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors">Replace Section Content</button>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.verification && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.verification, null, 2)}</pre>
)}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<List>
{toolResult.citations.map((c: any, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c.citation_text || c.title || c.source} secondary={c.source} />
</ListItem>
))}
</List>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult, null, 2)}</pre>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeToolDialog}>Close</Button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<Button variant="contained" onClick={applyOptimizedContent}>Replace Section Content</Button>
)}
</DialogActions>
</Dialog>
</div>
</div>
)}
{/* Section Divider */}
<Divider sx={{ mt: 1.2, mb: 1, opacity: 0.3 }} />
<Divider sx={{ mt: 2, opacity: 0.2 }} />
{assistiveWriting.renderSelectionMenu()}
</div>
);
};

View File

@@ -191,72 +191,72 @@ const useBlogTextSelectionHandler = (
// Text selection handler with debouncing
const handleTextSelection = () => {
console.log('🔍 [BlogTextSelectionHandler] handleTextSelection called');
// Clear any existing timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Debounce the selection handling
selectionTimeoutRef.current = setTimeout(() => {
try {
const sel = window.getSelection();
console.log('🔍 [BlogTextSelectionHandler] Selection object (debounced):', sel);
if (!sel || sel.rangeCount === 0) {
console.log('🔍 [BlogTextSelectionHandler] No selection or range count is 0');
setSelectionMenu(null);
return;
}
const text = (sel.toString() || '').trim();
console.log('🔍 [BlogTextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
if (!text || text.length < 10) {
console.log('🔍 [BlogTextSelectionHandler] Text too short or empty, hiding menu');
setSelectionMenu(null);
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Range rect:', rect);
// Check if rect has valid dimensions
if (rect.width === 0 && rect.height === 0) {
console.log('🔍 [BlogTextSelectionHandler] Invalid rect dimensions, trying alternative positioning');
// Try to get position from the textarea element itself
if (contentRef.current) {
const textareaRect = contentRef.current.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Textarea rect:', textareaRect);
// Position menu near the textarea center
const x = Math.max(8, Math.min(textareaRect.left + (textareaRect.width / 2), window.innerWidth - 280));
const y = Math.max(8, textareaRect.top + window.scrollY - 60);
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Using textarea position:', menuPosition);
setSelectionMenu(menuPosition);
return;
let text = '';
let rect: DOMRect | null = null;
const el = contentRef.current;
if (el instanceof HTMLTextAreaElement) {
const start = el.selectionStart;
const end = el.selectionEnd;
if (start !== end) {
text = el.value.substring(start, end).trim();
try {
const { selectionStart, selectionEnd } = el;
if (selectionStart !== null && selectionEnd !== null) {
const textRect = el.getBoundingClientRect();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
const linesBefore = el.value.substring(0, selectionStart).split('\n').length - 1;
rect = new DOMRect(
textRect.left + 10,
textRect.top + (linesBefore * lineHeight) + 10,
100,
20
);
}
} catch (_) {}
}
} else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
text = (sel.toString() || '').trim();
if (text.length >= 10) {
rect = sel.getRangeAt(0).getBoundingClientRect();
}
}
}
// Use viewport coordinates for absolute positioning
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280)); // Account for menu width
const y = Math.max(8, rect.top + window.scrollY - 60); // Position above selection
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
setSelectionMenu(menuPosition);
if (!text || text.length < 10) {
setSelectionMenu(null);
return;
}
if (!rect || (rect.width === 0 && rect.height === 0)) {
if (el) {
const elRect = el.getBoundingClientRect();
const x = Math.max(8, Math.min(elRect.left + (elRect.width / 2), window.innerWidth - 280));
const y = Math.max(8, elRect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
return;
}
setSelectionMenu(null);
return;
}
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280));
const y = Math.max(8, rect.top + window.scrollY - 60);
setSelectionMenu({ x, y, text });
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error handling text selection:', error);
console.error('Text selection error:', error);
setSelectionMenu(null);
}
}, 150); // 150ms debounce
}, 150);
};
return {

View File

@@ -1,8 +1,6 @@
import React from 'react';
import { Paper, Button, Chip } from '@mui/material';
import { Paper, Chip } from '@mui/material';
import {
Add as AddIcon,
AutoAwesome as AutoAwesomeIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
GpsFixed as GpsFixedIcon,
@@ -14,74 +12,112 @@ 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 className="sticky top-24 hidden lg:block">
<Paper elevation={2} className="p-4 rounded-xl shadow-lg border border-gray-100">
<h3 className="font-bold text-lg mb-4 text-gray-700">Editor's Toolkit</h3>
<div className="space-y-3 mb-6">
<Button
fullWidth
variant="contained"
startIcon={<AutoAwesomeIcon />}
className="!bg-gradient-to-r !from-indigo-500 !to-purple-500 !capitalize !font-semibold !rounded-lg"
>
ALwrity it
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
className="!capitalize !rounded-lg"
>
Add Section
</Button>
<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>
<div className="mb-6">
<h4 className="font-semibold text-sm text-gray-600 mb-3">Outline</h4>
<ul className="space-y-2">
{sections.map(section => (
<li key={section.id}>
<a
href={`#section-${section.id}`}
className="text-sm text-gray-500 hover:text-indigo-600 transition-colors flex items-start"
>
<span className="mr-2 font-semibold">{section.id}.</span>
<span className="flex-1">{section.title}</span>
</a>
</li>
))}
</ul>
{/* Stats */}
<div className="space-y-2 mb-5">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Sections</span>
<span className="font-medium text-gray-800">{sections.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Words</span>
<span className="font-medium text-gray-800">{totalWords.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Target</span>
<span className="font-medium text-gray-800">{wordTarget.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Reading time</span>
<span className="font-medium text-gray-800">{Math.max(1, Math.ceil(totalWords / 200))} min</span>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-semibold text-sm text-gray-600 mb-3">SuperPowers</h4>
<div className="border-t border-gray-100 pt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Research Tools</h4>
<div className="flex flex-wrap gap-2">
<Chip
icon={<BarChartIcon />}
label="Research"
size="small"
clickable
variant="outlined"
<Chip
icon={<BarChartIcon sx={{ fontSize: 14 }} />}
label="Keywords"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
<Chip
icon={<HubIcon />}
label="Source Mapping"
size="small"
clickable
variant="outlined"
<Chip
icon={<HubIcon sx={{ fontSize: 14 }} />}
label="Sources"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
<Chip
icon={<GpsFixedIcon />}
label="Grounding"
size="small"
clickable
variant="outlined"
<Chip
icon={<GpsFixedIcon sx={{ fontSize: 14 }} />}
label="Grounding"
size="small"
variant="outlined"
sx={{
fontSize: '12px',
height: 28,
'&:hover': { borderColor: '#4f46e5', color: '#4f46e5', backgroundColor: 'rgba(79, 70, 229, 0.04)' },
transition: 'all 0.15s ease',
}}
/>
</div>
</div>
{/* Section navigation */}
{sections.length > 0 && (
<div className="border-t border-gray-100 pt-4 mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">On this page</h4>
<nav className="space-y-1">
{sections.map((section, i) => (
<a
key={section.id}
href={`#section-${section.id}`}
className="block text-sm text-gray-500 hover:text-indigo-600 transition-colors py-1 truncate"
>
<span className="text-xs text-gray-300 mr-2">{i + 1}.</span>
{section.title || `Section ${i + 1}`}
</a>
))}
</nav>
</div>
)}
</Paper>
<div className="text-center text-xs text-gray-400 mt-4">
<span>{sections.length} sections</span> &bull; <span>{totalWords} words total</span>
</div>
</div>
);
};

View File

@@ -44,6 +44,9 @@ const useSmartTypingAssist = (
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastGeneratedAtRef = useRef<number>(0);
const hasShownFirstRef = useRef(false);
const isGeneratingRef = useRef(false);
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
// Quality improvement tracking
const [suggestionStats, setSuggestionStats] = useState({
@@ -64,13 +67,14 @@ const useSmartTypingAssist = (
debug.log('[SmartTypingAssist] Starting suggestion generation...');
setIsGeneratingSuggestion(true);
isGeneratingRef.current = true;
try {
// Import the assistive writing API
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
debug.log('[SmartTypingAssist] Calling assistive writing API...');
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
const response = await assistiveWritingApi.getSuggestion(currentText);
if (response.success && response.suggestions.length > 0) {
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
@@ -94,23 +98,19 @@ const useSmartTypingAssist = (
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
const maxHeight = 350;
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
@@ -121,118 +121,37 @@ const useSmartTypingAssist = (
sources: firstSuggestion.sources
});
}
} else {
debug.log('[SmartTypingAssist] No suggestions received from API');
// Fallback to generic suggestions if API fails
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
// Try to position below the editor
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// If it would be cut off at the bottom, position above instead
if (y + maxHeight > window.innerHeight - 20) {
y = rect.top - maxHeight - 10;
// If it would be cut off at the top, position in viewport center
if (y < 20) {
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
}
}
// Ensure it's never cut off
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
}
} catch (error) {
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
// Fallback to generic suggestions on error
const fallbackSuggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = fallbackSuggestions[Math.floor(Math.random() * fallbackSuggestions.length)];
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const maxWidth = 420;
const maxHeight = 160;
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 5;
if (y > window.innerHeight - maxHeight) {
y = window.innerHeight - (maxHeight + 20);
x = Math.max(20, window.innerWidth - (maxWidth + 20));
}
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
} finally {
setIsGeneratingSuggestion(false);
isGeneratingRef.current = false;
}
};
const handleTypingChange = (newText: string) => {
// Not logging this as it fires on every keystroke - too noisy
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Clear any existing suggestion when user types
setSmartSuggestion(null);
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
const cooldownMs = 15000; // 15s cooldown between suggestions
const cooldownMs = 15000;
const now = Date.now();
const sinceLast = now - lastGeneratedAtRef.current;
// First time suggestion appears automatically with sufficient content
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
if (!hasShownFirstRef.current && newText.length > 50 && !isGeneratingRef.current) {
debug.log('[SmartTypingAssist] Generating first suggestion');
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
lastGeneratedAtRef.current = now;
}
// After first time, show "Continue writing" prompt instead of random suggestions
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
} else if (hasShownFirstRef.current && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
setShowContinueWritingPrompt(true);
}
// Removed verbose log about skipping prompts as it's too noisy
}, 3000); // 3 second pause before suggesting
}, 3000);
};
const handleAcceptSuggestion = () => {
@@ -350,6 +269,11 @@ const useSmartTypingAssist = (
};
};
// Sync refs with state so timeout callbacks always read latest values
useEffect(() => { hasShownFirstRef.current = hasShownFirstSuggestion; }, [hasShownFirstSuggestion]);
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {

View File

@@ -21,7 +21,7 @@ import {
Avatar
} from '@mui/material';
import { apiClient } from '../../../api/client';
import { isPodcastOnlyDemoMode } from '../../../utils/demoMode';
import { isFeatureOnlyMode } from '../../../utils/demoMode';
import {
CheckCircle as HealthyIcon,
Warning as WarningIcon,
@@ -91,8 +91,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
const [, setCachePerf] = useState<{ hits: number; misses: number; hit_rate: number } | null>(null);
const fetchStatus = async () => {
// Skip system status checks in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip system status checks in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setStatusData({
status: 'unknown',
icon: '⚪',
@@ -131,8 +131,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
};
const fetchDetailedStats = async () => {
// Skip detailed stats in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip detailed stats in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setChartData([]);
return;
}
@@ -182,8 +182,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
useEffect(() => {
fetchStatus();
// Skip detailed stats in podcast-only mode
if (!isPodcastOnlyDemoMode()) {
// Skip detailed stats in feature-limited mode
if (!isFeatureOnlyMode()) {
fetchDetailedStats();
}

View File

@@ -37,6 +37,7 @@ import {
Warning,
} from '@mui/icons-material';
import { ImageStudioLayout } from './ImageStudioLayout';
import { DashboardHeaderProps } from '../shared/types';
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
import { intentResearchApi } from '../../api/intentResearchApi';
import { AssetFilters as AssetFiltersComponent } from './AssetLibraryComponents/AssetFilters';
@@ -127,6 +128,29 @@ export const AssetLibrary: React.FC = () => {
return baseFilters;
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
const headerProps: DashboardHeaderProps | undefined = useMemo(() => {
if (!urlSourceModule) return undefined;
switch (urlSourceModule) {
case 'blog_writer':
return {
title: 'Blog Posts',
subtitle: 'Manage and review your published blog posts.',
};
case 'research_tools':
return {
title: 'Research Documents',
subtitle: 'Access and manage your research projects.',
};
case 'product_marketing':
return {
title: 'Marketing Assets',
subtitle: 'Marketing content generated by Product Marketing tools.',
};
default:
return undefined;
}
}, [urlSourceModule]);
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);
// Refetch assets when component mounts with research_tools filter to show latest drafts
@@ -338,7 +362,7 @@ export const AssetLibrary: React.FC = () => {
}, [assets, statusFilter, dateFilter]);
return (
<ImageStudioLayout>
<ImageStudioLayout headerProps={headerProps}>
<Paper
elevation={0}
sx={{
@@ -561,7 +585,13 @@ export const AssetLibrary: React.FC = () => {
No assets found
</Typography>
<Typography variant="body2">
Generated content from all ALwrity tools will appear here.
{urlSourceModule === 'blog_writer'
? 'No blog posts found. Generate your first blog post in Blog Writer.'
: urlSourceModule === 'research_tools'
? 'No research documents found. Start a new research project.'
: urlSourceModule === 'product_marketing'
? 'No marketing assets found. Create one in Product Marketing.'
: 'Generated content from all ALwrity tools will appear here.'}
</Typography>
</Box>
) : viewMode === 'list' ? (

View File

@@ -18,6 +18,7 @@ import {
} from '@mui/icons-material';
import { PreflightCheckResponse } from '../../services/billingService';
import { useNavigate } from 'react-router-dom';
import { saveNavigationState } from '../../utils/navigationState';
interface PreflightBlockDialogProps {
open: boolean;
@@ -41,6 +42,7 @@ export const PreflightBlockDialog: React.FC<PreflightBlockDialogProps> = ({
const limitInfo = blockedOperation?.limit_info;
const handleUpgrade = () => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
onClose();
};

View File

@@ -21,7 +21,7 @@ import {
import Warning from '@mui/icons-material/Warning';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../../api/client';
import { restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
import { saveNavigationState, restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
import { getEnabledFeatures, getDefaultLandingRoute } from '../../utils/demoMode';
import PlanCard from './PricingPage/PlanCard';
@@ -44,11 +44,12 @@ export interface SubscriptionPlan {
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
// New limit fields (optional for backward compatibility)
image_edit_calls?: number;
video_calls?: number;
audio_calls?: number;
ai_text_generation_calls_limit?: number; // Unified limit for Basic tier
ai_text_generation_calls_limit?: number;
exa_calls?: number;
wavespeed_calls?: number;
};
}
@@ -123,7 +124,22 @@ const PricingPage: React.FC = () => {
return;
}
// Full mode keeps existing onboarding redirect behavior.
// Try to restore navigation state (saved before redirect to pricing)
const navState = restoreNavigationState();
if (navState?.path && navState.path !== '/pricing' && navState.path !== '/onboarding') {
console.log('[PricingPage] Redirecting to saved navigation state:', navState.path);
navigate(navState.path);
return;
}
// Fallback: try legacy referrer
const referrer = sessionStorage.getItem('subscription_referrer');
if (referrer && referrer !== '/pricing') {
navigate(referrer);
return;
}
// Final fallback
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
if (onboardingComplete) {
navigate('/dashboard');
@@ -234,12 +250,26 @@ const PricingPage: React.FC = () => {
if (stripePublishableKey) {
console.log('[PricingPage] Initiating Stripe Checkout');
// Save current navigation state so we can return here after payment
// If we're already on /pricing, don't overwrite — the caller (e.g., SubscriptionGuard,
// UserBadge, PreflightBlockDialog) already saved the original page's state.
if (window.location.pathname !== '/pricing') {
saveNavigationState(window.location.pathname);
}
// Include return_to in success_url so InitialRouteHandler can restore navigation
const returnTo = window.location.pathname !== '/pricing' ? window.location.pathname : '';
const successUrlBase = isFeatureLimitedMode()
? `${window.location.origin}${getDefaultLandingRoute()}`
: `${window.location.origin}/dashboard`;
const successUrl = returnTo
? `${successUrlBase}?subscription=success&return_to=${encodeURIComponent(returnTo)}`
: `${successUrlBase}?subscription=success`;
const response = await apiClient.post('/api/subscription/create-checkout-session', {
tier: plan.tier,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
success_url: isFeatureLimitedMode()
? `${window.location.origin}${getDefaultLandingRoute()}?subscription=success`
: `${window.location.origin}/dashboard?subscription=success`,
success_url: successUrl,
cancel_url: `${window.location.origin}/pricing?subscription=cancel`,
});

View File

@@ -61,6 +61,8 @@ interface SubscriptionPlan {
video_calls?: number;
audio_calls?: number;
ai_text_generation_calls_limit?: number;
exa_calls?: number;
wavespeed_calls?: number;
};
}
@@ -864,6 +866,32 @@ const PlanCard: React.FC<PlanCardProps> = ({
</ListItem>
)}
{(plan.limits.exa_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<SearchIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.exa_calls} Exa AI Searches`}
secondary="AI-powered search and content discovery"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{(plan.limits.wavespeed_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary={`${plan.limits.wavespeed_calls} WaveSpeed AI Calls`}
secondary="TTS, video, image, and LLM via Minimax"
sx={{ '& .MuiListItemText-primary': { fontSize: '0.875rem' } }}
/>
</ListItem>
)}
{plan.limits.monthly_cost > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { researchCache } from '../../../services/researchCache';
import { WizardState } from '../types/research.types';
import { researchEngineApi, ResearchEngineRequest } from '../../../services/researchEngineApi';
@@ -17,6 +17,8 @@ export const useResearchExecution = () => {
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<any>(null);
const keywordsRef = useRef<string[]>([]);
// Intent-driven research state
const [isAnalyzingIntent, setIsAnalyzingIntent] = useState(false);
const [intentAnalysis, setIntentAnalysis] = useState<AnalyzeIntentResponse | null>(null);
@@ -45,9 +47,9 @@ export const useResearchExecution = () => {
const polling = useResearchPolling({
onComplete: (result) => {
if (result && result.keywords) {
if (result) {
researchCache.cacheResult(
result.keywords,
keywordsRef.current,
'General',
'General',
result
@@ -68,6 +70,8 @@ export const useResearchExecution = () => {
setError(null);
try {
keywordsRef.current = state.keywords;
// Check cache first
const cachedResult = researchCache.getCachedResult(
state.keywords,

View File

@@ -16,6 +16,7 @@ import {
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useSubscriptionGuard, SubscriptionGuardOptions } from '../hooks/useSubscriptionGuard';
import { saveNavigationState } from '../utils/navigationState';
import LockIcon from '@mui/icons-material/Lock';
import UpgradeIcon from '@mui/icons-material/Upgrade';
@@ -98,6 +99,7 @@ export const SubscriptionGuard: React.FC<SubscriptionGuardProps> = ({
variant="contained"
startIcon={<UpgradeIcon />}
onClick={() => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
}}
>
@@ -123,6 +125,7 @@ export const SubscriptionGuard: React.FC<SubscriptionGuardProps> = ({
variant="outlined"
sx={{ mt: 1 }}
onClick={() => {
saveNavigationState(window.location.pathname);
navigate('/pricing');
}}
>

View File

@@ -6,7 +6,7 @@ import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface CostEfficiencyMetricsProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
}

View File

@@ -10,7 +10,7 @@ import { DashboardData } from '../../../../types/billing';
import { SystemHealth } from '../../../../types/monitoring';
interface MainMetricsGridProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
systemHealth: SystemHealth | null;
healthError: string | null;
sparklineData: {

View File

@@ -9,7 +9,7 @@ import { formatCurrency } from '../utils/formatting';
import { DashboardData } from '../../../../types/billing';
interface MonthlyBudgetUsageProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;

View File

@@ -7,7 +7,8 @@ import { UsageLimitRing } from '../../../shared/UsageLimitRing';
import { DashboardData } from '../../../../types/billing';
interface UsageLimitRingsProps {
currentUsage: DashboardData['current_usage'];
currentUsage: DashboardData['total_usage'];
currentPeriodUsage: DashboardData['total_usage'];
limits: DashboardData['limits'];
terminalTheme?: boolean;
TypographyComponent: typeof TerminalTypography | React.ComponentType<any>;
@@ -15,59 +16,34 @@ interface UsageLimitRingsProps {
/**
* UsageLimitRings - Displays circular progress rings for key usage limits
* Uses currentPeriodUsage for ring values (per-period budget), currentUsage for total display.
*/
export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
currentUsage,
currentPeriodUsage,
limits,
terminalTheme = false,
TypographyComponent
}) => {
// Calculate image calls - check multiple possible sources
const imageCalls = useMemo(() => {
// Primary: provider_breakdown.image
const imageFromBreakdown = currentUsage.provider_breakdown?.image?.calls ?? 0;
const imageEditFromBreakdown = currentUsage.provider_breakdown?.image_edit?.calls ?? 0;
// Fallback: Check if there's a stability key (legacy)
const stabilityFromBreakdown = currentUsage.provider_breakdown?.stability?.calls ?? 0;
// Sum all image-related calls
const total = imageFromBreakdown + imageEditFromBreakdown + stabilityFromBreakdown;
// Debug logging (can be removed in production)
if (total > 0 || imageFromBreakdown > 0 || stabilityFromBreakdown > 0) {
console.log('[UsageLimitRings] Image calls calculation:', {
image: imageFromBreakdown,
image_edit: imageEditFromBreakdown,
stability: stabilityFromBreakdown,
total,
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
});
}
return total;
}, [currentUsage.provider_breakdown]);
const periodBreakdown = currentPeriodUsage?.provider_breakdown || {};
const totalBreakdown = currentUsage.provider_breakdown || {};
// Calculate video calls - check multiple possible sources
// Calculate image calls from current period
const imageCalls = useMemo(() => {
const stabilityFromBreakdown = periodBreakdown.stability?.calls ?? 0;
const imageEditFromBreakdown = periodBreakdown.image_edit?.calls ?? 0;
return stabilityFromBreakdown + imageEditFromBreakdown;
}, [periodBreakdown]);
// Calculate video calls from current period
const videoCalls = useMemo(() => {
// Primary: provider_breakdown.video
const videoFromBreakdown = currentUsage.provider_breakdown?.video?.calls ?? 0;
// Debug logging (can be removed in production)
if (videoFromBreakdown > 0) {
console.log('[UsageLimitRings] Video calls calculation:', {
video: videoFromBreakdown,
provider_breakdown_keys: Object.keys(currentUsage.provider_breakdown || {})
});
}
return videoFromBreakdown;
}, [currentUsage.provider_breakdown]);
return periodBreakdown.video?.calls ?? 0;
}, [periodBreakdown]);
const keyLimits = [
{
label: 'AI Calls',
used: currentUsage.total_calls,
used: currentPeriodUsage?.total_calls ?? 0,
limit: limits.limits.ai_text_generation_calls || limits.limits.gemini_calls || limits.limits.openai_calls || 50,
color: '#3b82f6',
unlimited: limits.limits.ai_text_generation_calls === 0 && limits.limits.gemini_calls === 0 && limits.limits.openai_calls === 0,
@@ -88,7 +64,7 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
},
{
label: 'Audio',
used: currentUsage.provider_breakdown?.audio?.calls ?? 0,
used: periodBreakdown.audio?.calls ?? 0,
limit: limits.limits.audio_calls,
color: '#22c55e',
unlimited: limits.limits.audio_calls === 0,

View File

@@ -109,7 +109,7 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
if (!dashboardData) return null;
const { current_usage, limits, alerts } = dashboardData;
const { total_usage: current_usage, current_period_usage, limits, alerts } = dashboardData;
const mainCardStyles = terminalTheme
? {
@@ -187,6 +187,7 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({
{/* Usage Limit Rings */}
<UsageLimitRings
currentUsage={current_usage}
currentPeriodUsage={current_period_usage}
limits={limits}
terminalTheme={terminalTheme}
TypographyComponent={TypographyComponent}

View File

@@ -330,7 +330,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Active Providers Chips */}
{dashboardData && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{Object.entries(dashboardData.current_usage.provider_breakdown)
{Object.entries(dashboardData.total_usage.provider_breakdown)
.filter(([_, data]) => data && data.cost > 0)
.map(([provider, data]) => {
const providerData = data!; // Safe after filter
@@ -493,7 +493,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Top Row */}
<Grid item xs={12} md={4}>
<BillingOverview
usageStats={dashboardData.current_usage}
usageStats={dashboardData.total_usage}
onRefresh={fetchDashboardData}
terminalTheme={terminalTheme}
/>
@@ -519,8 +519,8 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Middle Row */}
<Grid item xs={12} md={6}>
<CostBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
providerBreakdown={dashboardData.total_usage.provider_breakdown}
totalCost={dashboardData.total_usage.total_cost}
/>
</Grid>
@@ -542,8 +542,8 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
{/* Bottom Row - Comprehensive API Breakdown */}
<Grid item xs={12} md={6}>
<ComprehensiveAPIBreakdown
providerBreakdown={dashboardData.current_usage.provider_breakdown}
totalCost={dashboardData.current_usage.total_cost}
providerBreakdown={dashboardData.total_usage.provider_breakdown}
totalCost={dashboardData.total_usage.total_cost}
/>
</Grid>

View File

@@ -64,7 +64,8 @@ interface UsageLimits {
}
interface DashboardData {
current_usage: UsageStats;
total_usage: UsageStats;
current_period_usage: UsageStats;
limits: UsageLimits;
projections: {
projected_monthly_cost: number;
@@ -248,14 +249,15 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
if (!dashboardData) return null;
const currentUsage = dashboardData.current_usage;
const totalUsage = dashboardData.total_usage;
const currentPeriodUsage = dashboardData.current_period_usage;
const limits = dashboardData.limits;
if (compact) {
// Compact view - show key metrics as chips
// Use current_usage for accurate cost (properly coerced from provider breakdown)
// Fallback to summary if current_usage is not available
const usageData = dashboardData?.current_usage || {
// Use total_usage for accurate cost (properly coerced from provider breakdown)
// Fallback to summary if total_usage is not available
const usageData = dashboardData?.total_usage || {
total_calls: dashboardData?.summary?.total_api_calls_this_month || 0,
total_cost: dashboardData?.summary?.total_cost_this_month || 0,
usage_status: dashboardData?.summary?.usage_status || 'active',
@@ -267,37 +269,49 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const monthlyLimit = dashboardData?.limits?.limits?.monthly_cost || 0;
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
// Build per-category usage summaries from provider_breakdown and limits
const providerBreakdown = usageData.provider_breakdown || {};
// Use current_period provider_breakdown for budget bars, total_usage for total display
const periodBreakdown = currentPeriodUsage?.provider_breakdown || {};
const totalBreakdown = usageData.provider_breakdown || {};
const providerLimits = dashboardData?.limits?.limits || {};
// Aggregate AI text calls (gemini + openai + anthropic + mistral)
const aiCalls = (providerBreakdown.gemini?.calls || 0) + (providerBreakdown.openai?.calls || 0) + (providerBreakdown.anthropic?.calls || 0) + (providerBreakdown.mistral?.calls || 0) + (providerBreakdown.huggingface?.calls || 0) + (providerBreakdown.wavespeed?.calls || 0);
// Aggregate AI text calls (gemini + openai + anthropic + mistral) — from current period
const aiCalls = (periodBreakdown.gemini?.calls || 0) + (periodBreakdown.openai?.calls || 0) + (periodBreakdown.anthropic?.calls || 0) + (periodBreakdown.mistral?.calls || 0) + (periodBreakdown.huggingface?.calls || 0) + (periodBreakdown.wavespeed?.calls || 0);
const aiCallLimit = providerLimits.ai_text_generation_calls || providerLimits.gemini_calls || 0;
// Image calls (stability + wavespeed image)
const imageCalls = (providerBreakdown.stability?.calls || 0) + (providerBreakdown.image_edit?.calls || 0);
// Image calls (stability + wavespeed image) — from current period
const imageCalls = (periodBreakdown.stability?.calls || 0) + (periodBreakdown.image_edit?.calls || 0);
const imageCallLimit = providerLimits.stability_calls || 0;
const imageTotal = (totalBreakdown.stability?.calls || 0) + (totalBreakdown.image_edit?.calls || 0);
// Audio calls
const audioCalls = providerBreakdown.audio?.calls || 0;
// Audio calls — from current period
const audioCalls = periodBreakdown.audio?.calls || 0;
const audioCallLimit = providerLimits.audio_calls || 0;
const audioTotal = totalBreakdown.audio?.calls || 0;
// Video calls
const videoCalls = providerBreakdown.video?.calls || 0;
// Video calls — from current period
const videoCalls = periodBreakdown.video?.calls || 0;
const videoCallLimit = providerLimits.video_calls || 0;
const videoTotal = totalBreakdown.video?.calls || 0;
// Research calls (exa + tavily + serper + firecrawl)
const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0);
// Research calls (exa + tavily + serper + firecrawl) — from current period
const researchCalls = (periodBreakdown.exa?.calls || 0) + (periodBreakdown.tavily?.calls || 0) + (periodBreakdown.serper?.calls || 0) + (periodBreakdown.firecrawl?.calls || 0);
const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0);
// WaveSpeed calls (all WaveSpeed API calls)
const wavespeedCalls = providerBreakdown.wavespeed?.calls || 0;
// WaveSpeed calls (all WaveSpeed API calls) — from current period
const wavespeedCalls = periodBreakdown.wavespeed?.calls || 0;
const wavespeedCallLimit = providerLimits.wavespeed_calls || 0;
const wavespeedTotal = totalBreakdown.wavespeed?.calls || 0;
const formatLimit = (used: number, limit: number) => {
if (limit === 0) return `${used} / ∞`;
return `${used} / ${limit}`;
// All-time totals for rows without separate total variables
const aiTotal = (totalBreakdown.gemini?.calls || 0) + (totalBreakdown.openai?.calls || 0) + (totalBreakdown.anthropic?.calls || 0) + (totalBreakdown.mistral?.calls || 0) + (totalBreakdown.huggingface?.calls || 0) + (totalBreakdown.wavespeed?.calls || 0);
const researchTotal = (totalBreakdown.exa?.calls || 0) + (totalBreakdown.tavily?.calls || 0) + (totalBreakdown.serper?.calls || 0) + (totalBreakdown.firecrawl?.calls || 0);
const formatLimit = (used: number, limit: number, total?: number) => {
const periodStr = limit === 0 ? `${used} / ∞` : `${used} / ${limit}`;
if (total !== undefined && total !== used) {
return `${periodStr} • Total: ${total}`;
}
return periodStr;
};
return (
@@ -434,7 +448,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(aiCalls, aiCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(aiCalls, aiCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(aiCalls, aiCallLimit)}
{formatLimit(aiCalls, aiCallLimit, aiTotal)}
</Typography>
</Box>
</Box>
@@ -449,7 +463,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(imageCalls, imageCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(imageCalls, imageCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(imageCalls, imageCallLimit)}
{formatLimit(imageCalls, imageCallLimit, imageTotal)}
</Typography>
</Box>
</Box>
@@ -464,7 +478,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(audioCalls, audioCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(audioCalls, audioCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(audioCalls, audioCallLimit)}
{formatLimit(audioCalls, audioCallLimit, audioTotal)}
</Typography>
</Box>
</Box>
@@ -479,7 +493,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(videoCalls, videoCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(videoCalls, videoCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(videoCalls, videoCallLimit)}
{formatLimit(videoCalls, videoCallLimit, videoTotal)}
</Typography>
</Box>
</Box>
@@ -494,7 +508,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(researchCalls, researchCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(researchCalls, researchCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(researchCalls, researchCallLimit)}
{formatLimit(researchCalls, researchCallLimit, researchTotal)}
</Typography>
</Box>
</Box>
@@ -509,7 +523,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(wavespeedCalls, wavespeedCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(wavespeedCalls, wavespeedCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(wavespeedCalls, wavespeedCallLimit)}
{formatLimit(wavespeedCalls, wavespeedCallLimit, wavespeedTotal)}
</Typography>
</Box>
</Box>
@@ -552,7 +566,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
}
// Full dashboard view (for dedicated usage page)
const usageData = dashboardData?.current_usage || {
const usageData = dashboardData?.total_usage || {
total_calls: dashboardData?.summary?.total_api_calls_this_month || 0,
total_cost: dashboardData?.summary?.total_cost_this_month || 0,
provider_breakdown: {}

View File

@@ -4,12 +4,13 @@ import { useUser, useClerk } from '@clerk/clerk-react';
import { useSubscription } from '../../contexts/SubscriptionContext';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
import UsageDashboard from './UsageDashboard';
import { isPodcastOnlyDemoMode } from '../../utils/demoMode';
import { isFeatureOnlyMode } from '../../utils/demoMode';
import {
apiClient,
isBackendCooldownActive,
logBackendCooldownSkipOnce,
} from '../../api/client';
import { saveNavigationState } from '../../utils/navigationState';
interface UserBadgeProps {
colorMode?: 'light' | 'dark';
@@ -31,8 +32,8 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
// Fetch system status for status bulb
useEffect(() => {
// Skip system status checks in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip system status checks in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
setSystemStatus('unknown');
return;
}
@@ -254,7 +255,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
<Divider sx={{ mx: 2 }} />
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
<MenuItem onClick={() => { handleClose(); saveNavigationState(window.location.pathname); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
Manage Subscription
</MenuItem>
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../services/blogWriterApi';
import { researchCache } from '../services/researchCache';
import { blogWriterCache } from '../services/blogWriterCache';
const MINOR_TITLE_WORDS = new Set([
'a', 'an', 'and', 'or', 'but', 'the', 'for', 'nor', 'on', 'at', 'to', 'from', 'by',
@@ -94,36 +95,70 @@ export const useBlogWriterState = () => {
// Cache recovery - restore most recent research on page load
useEffect(() => {
const cachedEntries = researchCache.getAllCachedEntries();
if (cachedEntries.length > 0) {
// Get the most recent cached research
const mostRecent = cachedEntries[0];
console.log('Restoring cached research from page load:', mostRecent.keywords);
setResearch(mostRecent.result);
// Also try to restore outline if it exists in localStorage
try {
const savedOutline = localStorage.getItem('blog_outline');
const savedTitleOptions = localStorage.getItem('blog_title_options');
const savedSelectedTitle = localStorage.getItem('blog_selected_title');
const restoreState = async () => {
const cachedEntries = researchCache.getAllCachedEntries();
if (cachedEntries.length > 0) {
// Get the most recent cached research
const mostRecent = cachedEntries[0];
console.log('Restoring cached research from page load:', mostRecent.keywords);
setResearch(mostRecent.result);
if (savedOutline) {
setOutline(JSON.parse(savedOutline));
// Also try to restore outline if it exists in localStorage
try {
const savedOutline = localStorage.getItem('blog_outline');
const savedTitleOptions = localStorage.getItem('blog_title_options');
const savedSelectedTitle = localStorage.getItem('blog_selected_title');
if (savedOutline) {
const parsedOutline = JSON.parse(savedOutline);
setOutline(parsedOutline);
// Restore content sections from cache when outline is available
const outlineIds = parsedOutline.map((s: any) => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent && Object.keys(cachedContent).length > 0) {
setSections(cachedContent);
console.log('Restored content sections from cache', { sections: Object.keys(cachedContent).length });
}
}
if (savedTitleOptions) {
setTitleOptions(JSON.parse(savedTitleOptions));
}
if (savedSelectedTitle) {
setSelectedTitle(savedSelectedTitle);
}
// Restore contentConfirmed from localStorage
const savedContentConfirmed = localStorage.getItem('blog_content_confirmed');
if (savedContentConfirmed === 'true') {
setContentConfirmed(true);
}
console.log('Restored outline, content, and title data from localStorage');
} catch (error) {
console.error('Error restoring outline data:', error);
}
if (savedTitleOptions) {
setTitleOptions(JSON.parse(savedTitleOptions));
}
if (savedSelectedTitle) {
setSelectedTitle(savedSelectedTitle);
}
console.log('Restored outline and title data from localStorage');
} catch (error) {
console.error('Error restoring outline data:', error);
}
}
};
restoreState();
}, []);
// Persist contentConfirmed to localStorage whenever it changes
useEffect(() => {
try {
localStorage.setItem('blog_content_confirmed', String(contentConfirmed));
} catch {}
}, [contentConfirmed]);
// Persist sections to blogWriterCache whenever they change
useEffect(() => {
const outlineIds = outline.map(s => String(s.id));
if (outlineIds.length > 0 && Object.keys(sections).length > 0) {
blogWriterCache.cacheContent(sections, outlineIds);
}
}, [sections, outline]);
// Handle research completion
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
setResearch(researchData);

View File

@@ -130,6 +130,13 @@ export const usePhaseNavigation = (
if (currentPhase === '') {
return; // Don't validate empty phase - it's intentional for landing page
}
// If user manually selected this phase, respect their choice even if data
// hasn't been restored yet (e.g., on page load before cache restoration).
// The data restoration effects will populate the necessary state shortly.
if (userSelectedPhase) {
return;
}
const current = phases.find(p => p.id === currentPhase);
if (!current) {
@@ -146,7 +153,7 @@ export const usePhaseNavigation = (
setCurrentPhase(fallback.id);
}
}
}, [phases, currentPhase, research]);
}, [phases, currentPhase, research, userSelectedPhase]);
// Auto-update current phase based on completion status (only if user hasn't manually selected a phase)
useEffect(() => {

View File

@@ -56,7 +56,7 @@ export const usePriority2Alerts = (
const generateAlerts = useCallback((data: DashboardData): Priority2Alert[] => {
const generatedAlerts: Priority2Alert[] = [];
const currentUsage = data.current_usage;
const currentUsage = data.total_usage;
const limits = data.limits;
const projections = data.projections;

View File

@@ -0,0 +1,120 @@
import { useState, useCallback, useRef } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../services/blogWriterApi';
import { useBlogWriterResearchPolling } from './usePolling';
import { researchCache } from '../services/researchCache';
export interface UseResearchSubmitOptions {
onResearchComplete?: (research: BlogResearchResponse) => void;
navigateToPhase?: (phase: string) => void;
}
export interface UseResearchSubmitReturn {
startResearch: (keywords: string, blogLength?: string, industry?: string, audience?: string) => Promise<BlogResearchResponse | null>;
isSubmitting: boolean;
showProgressModal: boolean;
setShowProgressModal: (show: boolean) => void;
currentMessage: string;
currentStatus: string;
progressMessages: Array<{ timestamp: string; message: string }>;
error: string | null;
result: BlogResearchResponse | null;
isPolling: boolean;
}
export const useResearchSubmit = ({
onResearchComplete,
navigateToPhase,
}: UseResearchSubmitOptions): UseResearchSubmitReturn => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showProgressModal, setShowProgressModal] = useState(false);
const [currentMessage, setCurrentMessage] = useState('');
const keywordListRef = useRef<string[]>([]);
const polling = useBlogWriterResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
},
onComplete: (result) => {
if (result) {
researchCache.cacheResult(
keywordListRef.current,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
onResearchComplete?.(result);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
},
onError: (error) => {
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
},
});
const startResearch = useCallback(async (
keywords: string,
blogLength: string = '1000',
industry: string = 'General',
audience: string = 'General',
): Promise<BlogResearchResponse | null> => {
const trimmed = keywords.trim();
if (!trimmed) return null;
setIsSubmitting(true);
try {
const keywordList = trimmed.includes(',')
? trimmed.split(',').map(k => k.trim()).filter(Boolean)
: [trimmed];
keywordListRef.current = keywordList;
const cachedResult = researchCache.getCachedResult(keywordList, industry, audience);
if (cachedResult) {
onResearchComplete?.(cachedResult);
setIsSubmitting(false);
return cachedResult;
}
navigateToPhase?.('research');
setShowProgressModal(true);
setCurrentMessage('Starting research...');
const payload: BlogResearchRequest = {
keywords: keywordList,
industry,
target_audience: audience,
word_count_target: parseInt(blogLength),
};
const { task_id } = await blogWriterApi.startResearch(payload);
polling.startPolling(task_id);
return null;
} catch (error) {
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
throw error;
}
}, [onResearchComplete, navigateToPhase, polling]);
return {
startResearch,
isSubmitting,
showProgressModal,
setShowProgressModal,
currentMessage,
currentStatus: polling.currentStatus,
progressMessages: polling.progressMessages,
error: polling.error,
result: polling.result,
isPolling: polling.isPolling,
};
};
export default useResearchSubmit;

View File

@@ -3,7 +3,7 @@ import { useAuth } from '@clerk/clerk-react';
import { showToastNotification } from '../utils/toastNotifications';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../api/schedulerDashboard';
import { isBackendCooldownActive, logBackendCooldownSkipOnce } from '../api/client';
import { isPodcastOnlyDemoMode } from '../utils/demoMode';
import { isFeatureOnlyMode } from '../utils/demoMode';
/**
* Hook to poll for tasks needing intervention and show toast notifications
@@ -20,8 +20,8 @@ export function useSchedulerTaskAlerts(options: {
const shownTaskIdsRef = useRef<Set<number>>(new Set());
useEffect(() => {
// Skip scheduler alerts in podcast-only mode (endpoint not available)
if (isPodcastOnlyDemoMode()) {
// Skip scheduler alerts in feature-limited mode (endpoint not available)
if (isFeatureOnlyMode()) {
return;
}

View File

@@ -318,10 +318,12 @@ export const billingService = {
const raw = response.data.data as any;
// Coerce usage stats first to ensure proper typing
const currentUsage = coerceUsageStats(raw?.current_usage ?? raw);
const totalUsage = coerceUsageStats(raw?.total_usage ?? raw);
const currentPeriodUsage = coerceUsageStats(raw?.current_period_usage ?? {});
const coerced: DashboardData = {
current_usage: currentUsage,
total_usage: totalUsage,
current_period_usage: currentPeriodUsage,
trends: raw?.trends ?? {
periods: [],
total_calls: [],

View File

@@ -589,11 +589,8 @@ export interface AssistiveSuggestionResponse {
}
export const assistiveWritingApi = {
async getSuggestion(text: string, maxResults: number = 1): Promise<AssistiveSuggestionResponse> {
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', {
text,
max_results: maxResults
});
async getSuggestion(text: string): Promise<AssistiveSuggestionResponse> {
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text });
return data;
}
};

View File

@@ -22,7 +22,7 @@ import {
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatus } from "./storyWriterApi";
import { isPodcastOnlyDemoMode } from "../utils/demoMode";
import { isFeatureOnlyMode } from "../utils/demoMode";
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
@@ -360,7 +360,7 @@ export const podcastApi = {
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
const researchConfig = isPodcastOnlyDemoMode() ? null : await getResearchConfig();
const researchConfig = isFeatureOnlyMode() ? null : await getResearchConfig();
// Use AI-generated queries if available, fallback to legacy mapping
let queries: Query[] = [];

View File

@@ -2,7 +2,8 @@ import { z } from 'zod';
// Core data structures for billing and usage tracking
export interface DashboardData {
current_usage: UsageStats;
total_usage: UsageStats;
current_period_usage: UsageStats;
trends: UsageTrends;
limits: SubscriptionLimits;
alerts: UsageAlert[];
@@ -267,7 +268,8 @@ export const UsageStatsSchema = z.object({
});
export const DashboardDataSchema = z.object({
current_usage: UsageStatsSchema,
total_usage: UsageStatsSchema,
current_period_usage: UsageStatsSchema,
trends: z.object({
periods: z.array(z.string()),
total_calls: z.array(z.number()),

View File

@@ -0,0 +1,16 @@
export async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
export function getSeoCacheKey(contentHash: string, title?: string): string {
return `seo_cache:${contentHash}:${title || ''}`;
}

View File

@@ -41,26 +41,35 @@ let cachedFeatures: Set<string> | null = null;
/**
* Get enabled features from localStorage or environment.
* Returns a Set of enabled feature names.
*
* Priority: env var > localStorage > default "all"
* The env var (REACT_APP_ENABLED_FEATURES) takes precedence because it's
* the authoritative deployment config — stale localStorage values from
* previous sessions should not override it.
*/
export function getEnabledFeatures(): Set<string> {
if (cachedFeatures) {
return cachedFeatures;
}
const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY);
if (storageValue) {
const features = storageValue.toLowerCase().split(',').map(f => f.trim());
// Env var is the authoritative source (deployment config)
const envValue = process.env[PRIMARY_ENV_KEY];
if (envValue) {
const features = envValue.toLowerCase().split(',').map(f => f.trim());
if (features.includes('all')) {
cachedFeatures = new Set(['all']);
return cachedFeatures;
}
cachedFeatures = new Set(features.filter(f => f));
// Sync localStorage to match env var
try { localStorage.setItem(PRIMARY_STORAGE_KEY, envValue); } catch {}
return cachedFeatures;
}
const envValue = process.env[PRIMARY_ENV_KEY];
if (envValue) {
const features = envValue.toLowerCase().split(',').map(f => f.trim());
// Fallback to localStorage (for runtime overrides in dev)
const storageValue = localStorage.getItem(PRIMARY_STORAGE_KEY);
if (storageValue) {
const features = storageValue.toLowerCase().split(',').map(f => f.trim());
if (features.includes('all')) {
cachedFeatures = new Set(['all']);
return cachedFeatures;
@@ -108,19 +117,31 @@ export function getSingleFeature(): string | null {
return null;
}
/**
* Priority-ordered list of features to their landing routes.
* The first enabled feature in this list determines the landing route.
*/
const FEATURE_ROUTE_PRIORITY: [string, string][] = [
['podcast', '/podcast-maker'],
['blog_writer', '/blog-writer'],
['story', '/story-writer'],
['image', '/image-studio'],
['video', '/video-studio'],
['campaign', '/campaign-creator'],
['social', '/social-media'],
['seo', '/seo-tools'],
['research', '/research-dashboard'],
];
/**
* Get the default landing route based on enabled features.
* When multiple features are enabled, routes to the highest-priority one.
*/
export function getDefaultLandingRoute(): string {
const enabled = getEnabledFeatures();
if (enabled.has('all')) return '/dashboard';
const singleFeature = getSingleFeature();
if (singleFeature) {
const routeMap: Record<string, string> = {
'podcast': '/podcast-maker',
'blog_writer': '/blog-writer',
};
return routeMap[singleFeature] || '/dashboard';
for (const [feature, route] of FEATURE_ROUTE_PRIORITY) {
if (enabled.has(feature)) return route;
}
return '/dashboard';
}