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:
@@ -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
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> • <span>{totalWords} words total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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' } }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
120
frontend/src/hooks/useResearchSubmit.ts
Normal file
120
frontend/src/hooks/useResearchSubmit.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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()),
|
||||
|
||||
16
frontend/src/utils/contentHash.ts
Normal file
16
frontend/src/utils/contentHash.ts
Normal 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 || ''}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user