Base code
This commit is contained in:
605
frontend/src/App.tsx
Normal file
605
frontend/src/App.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import {
|
||||
VideoStudioDashboard,
|
||||
CreateVideo,
|
||||
AvatarVideo,
|
||||
EnhanceVideo,
|
||||
ExtendVideo,
|
||||
EditVideo,
|
||||
TransformVideo,
|
||||
SocialVideo,
|
||||
FaceSwap,
|
||||
VideoTranslate,
|
||||
VideoBackgroundRemover,
|
||||
AddAudioToVideo,
|
||||
LibraryVideo,
|
||||
} from './components/VideoStudio';
|
||||
import { ProductMarketingDashboard } from './components/ProductMarketing';
|
||||
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
|
||||
import PricingPage from './components/Pricing/PricingPage';
|
||||
import WixTestPage from './components/WixTestPage/WixTestPage';
|
||||
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
|
||||
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
|
||||
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
|
||||
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
|
||||
import ResearchTest from './pages/ResearchTest';
|
||||
import IntentResearchTest from './pages/IntentResearchTest';
|
||||
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
import Landing from './components/Landing/Landing';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
|
||||
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
|
||||
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
|
||||
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
|
||||
|
||||
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
|
||||
import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl';
|
||||
import { setBillingAuthTokenGetter } from './services/billingService';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
|
||||
// interface OnboardingStatus {
|
||||
// onboarding_required: boolean;
|
||||
// onboarding_complete: boolean;
|
||||
// current_step?: number;
|
||||
// total_steps?: number;
|
||||
// completion_percentage?: number;
|
||||
// }
|
||||
|
||||
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Component to handle initial routing based on subscription and onboarding status
|
||||
// Flow: Subscription → Onboarding → Dashboard
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
|
||||
const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription();
|
||||
// Note: subscriptionError is available for future error handling
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Poll for OAuth token alerts and show toast notifications
|
||||
// Only enabled when user is authenticated (has subscription)
|
||||
useOAuthTokenAlerts({
|
||||
enabled: subscription?.active === true,
|
||||
interval: 60000, // Poll every 1 minute
|
||||
});
|
||||
|
||||
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||
useEffect(() => {
|
||||
// Delay subscription check slightly to allow auth token getter to be installed first
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkSubscription().catch((err) => {
|
||||
console.error('Error checking subscription (non-blocking):', err);
|
||||
|
||||
// Check if it's a connection error - handle it locally
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
// Don't block routing on subscription check errors - allow graceful degradation
|
||||
});
|
||||
}, 100); // Small delay to ensure TokenInstaller has run
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// Initialize onboarding only after subscription is confirmed
|
||||
useEffect(() => {
|
||||
if (subscription && !subscriptionLoading) {
|
||||
// Check if user is new (no subscription record at all)
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
console.log('InitialRouteHandler: Subscription data received:', {
|
||||
plan: subscription.plan,
|
||||
active: subscription.active,
|
||||
isNewUser,
|
||||
subscriptionLoading
|
||||
});
|
||||
|
||||
if (subscription.active && !isNewUser) {
|
||||
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
|
||||
initializeOnboarding();
|
||||
}
|
||||
}
|
||||
}, [subscription, subscriptionLoading, initializeOnboarding]);
|
||||
|
||||
// Handle connection error - show connection error page
|
||||
if (connectionError.hasError) {
|
||||
const handleRetry = () => {
|
||||
setConnectionError({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
// Re-trigger the subscription check using context
|
||||
checkSubscription().catch((err) => {
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionErrorPage
|
||||
onRetry={handleRetry}
|
||||
onGoHome={handleGoHome}
|
||||
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
|
||||
title="Connection Error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - only wait for onboarding init, not subscription check
|
||||
// Subscription check is non-blocking and happens in background
|
||||
const waitingForOnboardingInit = loading || !data;
|
||||
if (loading || waitingForOnboardingInit) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
p={3}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
{error}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision tree for SIGNED-IN users:
|
||||
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
|
||||
|
||||
// 1. If subscription is still loading, show loading state
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. No subscription data yet - handle gracefully
|
||||
// If onboarding is complete, allow access to dashboard (user already went through flow)
|
||||
// If onboarding not complete, check if subscription check is still loading or failed
|
||||
if (!subscription) {
|
||||
if (isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete and no subscription data
|
||||
// If subscription check is still loading, show loading state
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Subscription check completed but returned null/undefined
|
||||
// This likely means no subscription - redirect to pricing
|
||||
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
|
||||
// 3. Check subscription status first
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
// No active subscription → Show modal (SubscriptionContext handles this)
|
||||
// Don't redirect immediately - let the modal show first
|
||||
// User can click "Renew Subscription" button in modal to go to pricing
|
||||
// Or click "Maybe Later" to dismiss (but they still can't use features)
|
||||
if (isNewUser || !subscription.active) {
|
||||
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
|
||||
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
|
||||
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
|
||||
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
|
||||
// For new users (no subscription at all), redirect to pricing immediately
|
||||
if (isNewUser) {
|
||||
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
// For existing users with inactive subscription, show modal but don't redirect immediately
|
||||
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
|
||||
// Allow access to dashboard (modal will be shown and block functionality)
|
||||
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
|
||||
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
|
||||
}
|
||||
|
||||
// 4. Has active subscription, check onboarding status
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// 5. Has subscription AND completed onboarding → Dashboard
|
||||
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
};
|
||||
|
||||
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||
const RootRoute: React.FC = () => {
|
||||
const { isSignedIn } = useAuth();
|
||||
if (isSignedIn) {
|
||||
return <InitialRouteHandler />;
|
||||
}
|
||||
return <Landing />;
|
||||
};
|
||||
|
||||
// Installs Clerk auth token getter into axios clients and stores user_id
|
||||
// Must render under ClerkProvider
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||
|
||||
// Store user_id in localStorage when user signs in
|
||||
useEffect(() => {
|
||||
if (isSignedIn && userId) {
|
||||
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
|
||||
localStorage.setItem('user_id', userId);
|
||||
|
||||
// Trigger event to notify SubscriptionContext that user is authenticated
|
||||
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
|
||||
} else if (!isSignedIn) {
|
||||
// Clear user_id when signed out
|
||||
console.log('TokenInstaller: Clearing user_id from localStorage');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
}, [isSignedIn, userId]);
|
||||
|
||||
// Install token getter for API calls
|
||||
useEffect(() => {
|
||||
const tokenGetter = async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
// If a template is provided and it's not a placeholder, request a template-specific JWT
|
||||
if (template && template !== 'your_jwt_template_name_here') {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Set token getter for main API client
|
||||
setAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for billing API client (same function)
|
||||
setBillingAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for media blob URL fetcher (for authenticated image/video requests)
|
||||
setMediaAuthTokenGetter(tokenGetter);
|
||||
}, [getToken]);
|
||||
|
||||
// Install Clerk signOut function for handling expired tokens
|
||||
useEffect(() => {
|
||||
if (signOut) {
|
||||
setClerkSignOut(async () => {
|
||||
await signOut();
|
||||
});
|
||||
}
|
||||
}, [signOut]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// React Hooks MUST be at the top before any conditionals
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Get CopilotKit key from localStorage or .env
|
||||
const [copilotApiKey, setCopilotApiKey] = useState(() => {
|
||||
const savedKey = localStorage.getItem('copilotkit_api_key');
|
||||
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
const key = (savedKey || envKey).trim();
|
||||
|
||||
// Validate key format if present
|
||||
if (key && !key.startsWith('ck_pub_')) {
|
||||
console.warn('CopilotKit API key format invalid - must start with ck_pub_');
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
// Initialize app - loading state will be managed by InitialRouteHandler
|
||||
useEffect(() => {
|
||||
// Remove manual health check - connection errors are handled by ErrorBoundary
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Listen for CopilotKit key updates
|
||||
useEffect(() => {
|
||||
const handleKeyUpdate = (event: CustomEvent) => {
|
||||
const newKey = event.detail?.apiKey;
|
||||
if (newKey) {
|
||||
console.log('App: CopilotKit key updated, reloading...');
|
||||
setCopilotApiKey(newKey);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
return () => window.removeEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
}, []);
|
||||
|
||||
// Token installer must be inside ClerkProvider; see TokenInstaller below
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Connecting to ALwrity...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Get environment variables with fallbacks
|
||||
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||
|
||||
// Show error if required keys are missing
|
||||
if (!clerkPublishableKey) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="h6">
|
||||
Missing Clerk Publishable Key
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render app with or without CopilotKit based on whether we have a key
|
||||
const renderApp = () => {
|
||||
const appContent = (
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<TokenInstaller />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRoute />} />
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ErrorBoundary context="Onboarding Wizard" showDetails>
|
||||
<Wizard />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
{/* Error Boundary Testing - Development Only */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Route path="/error-test" element={<ErrorBoundaryTest />} />
|
||||
)}
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
|
||||
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
|
||||
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
|
||||
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio" element={<ProtectedRoute><VideoStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/create" element={<ProtectedRoute><CreateVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/avatar" element={<ProtectedRoute><AvatarVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/enhance" element={<ProtectedRoute><EnhanceVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/extend" element={<ProtectedRoute><ExtendVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/edit" element={<ProtectedRoute><EditVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/transform" element={<ProtectedRoute><TransformVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/social" element={<ProtectedRoute><SocialVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FaceSwap /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-translate" element={<ProtectedRoute><VideoTranslate /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><VideoBackgroundRemover /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><AddAudioToVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/library" element={<ProtectedRoute><LibraryVideo /></ProtectedRoute>} />
|
||||
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
|
||||
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/research-test" element={<ResearchTest />} />
|
||||
<Route path="/intent-research" element={<IntentResearchTest />} />
|
||||
<Route path="/wix-test" element={<WixTestPage />} />
|
||||
<Route path="/wix-test-direct" element={<WixTestPage />} />
|
||||
<Route path="/wix/callback" element={<WixCallbackPage />} />
|
||||
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
<Route path="/bing/callback" element={<BingCallbackPage />} />
|
||||
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
);
|
||||
|
||||
// Only wrap with CopilotKit if we have a valid key
|
||||
if (copilotApiKey && copilotApiKey.trim()) {
|
||||
// Enhanced error handler that updates health context
|
||||
const handleCopilotKitError = (e: any) => {
|
||||
console.error("CopilotKit Error:", e);
|
||||
|
||||
// Try to get health context if available
|
||||
// We'll use a custom event to notify health context since we can't access it directly here
|
||||
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
|
||||
const errorType = errorMessage.toLowerCase();
|
||||
|
||||
// Differentiate between fatal and transient errors
|
||||
const isFatalError =
|
||||
errorType.includes('cors') ||
|
||||
errorType.includes('ssl') ||
|
||||
errorType.includes('certificate') ||
|
||||
errorType.includes('403') ||
|
||||
errorType.includes('forbidden') ||
|
||||
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
|
||||
|
||||
// Dispatch event for health context to listen to
|
||||
window.dispatchEvent(new CustomEvent('copilotkit-error', {
|
||||
detail: {
|
||||
error: e,
|
||||
errorMessage,
|
||||
isFatal: isFatalError,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
context="CopilotKit"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
fallback={
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="warning" gutterBottom>
|
||||
Chat Unavailable
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
CopilotKit encountered an error. The app continues to work with manual controls.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={handleCopilotKitError}
|
||||
>
|
||||
{appContent}
|
||||
</CopilotKit>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Return app without CopilotKit if no key available
|
||||
return appContent;
|
||||
};
|
||||
|
||||
// Determine initial health status based on whether CopilotKit key is available
|
||||
const hasCopilotKitKey = copilotApiKey && copilotApiKey.trim();
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
context="Application Root"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
onError={(error, errorInfo) => {
|
||||
// Custom error handler - send to analytics/monitoring
|
||||
console.error('Global error caught:', { error, errorInfo });
|
||||
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||
}}
|
||||
>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||
<SubscriptionProvider>
|
||||
<OnboardingProvider>
|
||||
<CopilotKitHealthProvider initialHealthStatus={!!hasCopilotKitKey}>
|
||||
<CopilotKitDegradedBanner />
|
||||
{renderApp()}
|
||||
</CopilotKitHealthProvider>
|
||||
</OnboardingProvider>
|
||||
</SubscriptionProvider>
|
||||
</ClerkProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
225
frontend/src/api/analytics.ts
Normal file
225
frontend/src/api/analytics.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Analytics API Service
|
||||
*
|
||||
* Handles communication with the backend analytics endpoints for retrieving
|
||||
* platform analytics data from connected services like GSC, Wix, and WordPress.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// Types
|
||||
export interface AnalyticsMetrics {
|
||||
total_clicks?: number;
|
||||
total_impressions?: number;
|
||||
avg_ctr?: number;
|
||||
avg_position?: number;
|
||||
total_queries?: number;
|
||||
top_queries?: Array<{
|
||||
query: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>;
|
||||
top_pages?: Array<{
|
||||
page: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
}>;
|
||||
// Additional properties for Bing analytics
|
||||
connection_status?: string;
|
||||
connected_sites?: number;
|
||||
sites?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
Url?: string; // Bing API uses uppercase Url
|
||||
status?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}>;
|
||||
connected_since?: string;
|
||||
scope?: string;
|
||||
insights?: any;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PlatformAnalytics {
|
||||
platform: string;
|
||||
metrics: AnalyticsMetrics;
|
||||
date_range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
last_updated: string;
|
||||
status: 'success' | 'error' | 'partial';
|
||||
error_message?: string;
|
||||
// Additional properties that may be present in analytics data
|
||||
connection_status?: string;
|
||||
sites?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
Url?: string; // Bing API uses uppercase Url
|
||||
status?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}>;
|
||||
connected_sites?: number;
|
||||
connected_since?: string;
|
||||
scope?: string;
|
||||
insights?: any;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
total_platforms: number;
|
||||
connected_platforms: number;
|
||||
successful_data: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
overall_ctr: number;
|
||||
platforms: Record<string, {
|
||||
status: string;
|
||||
last_updated: string;
|
||||
metrics_count?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
success: boolean;
|
||||
data: Record<string, PlatformAnalytics>;
|
||||
summary: AnalyticsSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformConnectionStatus {
|
||||
connected: boolean;
|
||||
sites_count: number;
|
||||
sites: Array<{
|
||||
siteUrl?: string;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatusResponse {
|
||||
success: boolean;
|
||||
platforms: Record<string, PlatformConnectionStatus>;
|
||||
total_connected: number;
|
||||
}
|
||||
|
||||
class AnalyticsAPI {
|
||||
private baseUrl = '/api/analytics';
|
||||
|
||||
/**
|
||||
* Get connection status for all platforms
|
||||
*/
|
||||
async getPlatformStatus(): Promise<PlatformStatusResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/platforms`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data from connected platforms
|
||||
*/
|
||||
async getAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
let url = `${this.baseUrl}/data`;
|
||||
|
||||
if (platforms && platforms.length > 0) {
|
||||
const platformsParam = platforms.join(',');
|
||||
url += `?platforms=${encodeURIComponent(platformsParam)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data using POST method
|
||||
*/
|
||||
async getAnalyticsDataPost(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(`${this.baseUrl}/data`, {
|
||||
platforms,
|
||||
date_range: null // Could be extended to support custom date ranges
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics data (POST):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Search Console analytics specifically
|
||||
*/
|
||||
async getGSCAnalytics(): Promise<PlatformAnalytics> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/gsc`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting GSC analytics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics summary across all platforms
|
||||
*/
|
||||
async getAnalyticsSummary(): Promise<{
|
||||
success: boolean;
|
||||
summary: AnalyticsSummary;
|
||||
platforms_connected: number;
|
||||
platforms_total: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/summary`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics summary:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test endpoint - Get platform status without authentication
|
||||
*/
|
||||
async getTestPlatformStatus(): Promise<PlatformStatusResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/test/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting test platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test endpoint - Get mock analytics data without authentication
|
||||
*/
|
||||
async getTestAnalyticsData(): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/test/data`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting test analytics data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const analyticsAPI = new AnalyticsAPI();
|
||||
export default analyticsAPI;
|
||||
93
frontend/src/api/bingOAuth.ts
Normal file
93
frontend/src/api/bingOAuth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Bing Webmaster OAuth API Client
|
||||
* Handles Bing Webmaster Tools OAuth2 authentication flow
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface BingOAuthStatus {
|
||||
connected: boolean;
|
||||
sites: Array<{
|
||||
id: number;
|
||||
access_token: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
sites: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface BingOAuthResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface BingCallbackResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
class BingOAuthAPI {
|
||||
/**
|
||||
* Get Bing Webmaster OAuth authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<BingOAuthResponse> {
|
||||
try {
|
||||
console.log('BingOAuthAPI: Making GET request to /bing/auth/url');
|
||||
const response = await apiClient.get('/bing/auth/url');
|
||||
console.log('BingOAuthAPI: Response received:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('BingOAuthAPI: Error getting Bing OAuth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bing Webmaster connection status
|
||||
*/
|
||||
async getStatus(): Promise<BingOAuthStatus> {
|
||||
try {
|
||||
const response = await apiClient.get('/bing/status');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting Bing OAuth status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a Bing Webmaster site
|
||||
*/
|
||||
async disconnectSite(tokenId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/bing/disconnect/${tokenId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Bing site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Bing OAuth service
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string; version: string }> {
|
||||
try {
|
||||
const response = await apiClient.get('/bing/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking Bing OAuth health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bingOAuthAPI = new BingOAuthAPI();
|
||||
49
frontend/src/api/businessInfo.ts
Normal file
49
frontend/src/api/businessInfo.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
console.log('🔄 Loading Business Info API client...');
|
||||
|
||||
export interface BusinessInfo {
|
||||
user_id?: number;
|
||||
business_description: string;
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
business_goals?: string;
|
||||
}
|
||||
|
||||
export interface BusinessInfoResponse extends BusinessInfo {
|
||||
id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const businessInfoApi = {
|
||||
saveBusinessInfo: async (data: BusinessInfo): Promise<BusinessInfoResponse> => {
|
||||
console.log('API: Saving business info', data);
|
||||
const response = await apiClient.post<BusinessInfoResponse>('/onboarding/business-info', data);
|
||||
console.log('API: Business info saved successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBusinessInfo: async (id: number): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Getting business info for ID: ${id}`);
|
||||
const response = await apiClient.get<BusinessInfoResponse>(`/onboarding/business-info/${id}`);
|
||||
console.log('API: Business info retrieved successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBusinessInfoByUserId: async (userId: number): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Getting business info for user ID: ${userId}`);
|
||||
const response = await apiClient.get<BusinessInfoResponse>(`/onboarding/business-info/user/${userId}`);
|
||||
console.log('API: Business info retrieved successfully by user ID', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBusinessInfo: async (id: number, data: BusinessInfo): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Updating business info for ID: ${id}`, data);
|
||||
const response = await apiClient.put<BusinessInfoResponse>(`/onboarding/business-info/${id}`, data);
|
||||
console.log('API: Business info updated successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Business Info API client loaded successfully!');
|
||||
221
frontend/src/api/cachedAnalytics.ts
Normal file
221
frontend/src/api/cachedAnalytics.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Cached Analytics API Client
|
||||
*
|
||||
* Wraps the analytics API with intelligent caching to reduce redundant requests
|
||||
* and improve performance while managing cache invalidation.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import analyticsCache from '../services/analyticsCache';
|
||||
|
||||
interface PlatformAnalytics {
|
||||
platform: string;
|
||||
metrics: Record<string, any>;
|
||||
date_range: { start: string; end: string };
|
||||
last_updated: string;
|
||||
status: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsSummary {
|
||||
total_platforms: number;
|
||||
connected_platforms: number;
|
||||
successful_data: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
overall_ctr: number;
|
||||
platforms: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PlatformConnectionStatus {
|
||||
connected: boolean;
|
||||
sites_count: number;
|
||||
sites: any[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsResponse {
|
||||
data: Record<string, PlatformAnalytics>;
|
||||
summary: AnalyticsSummary;
|
||||
status: Record<string, PlatformConnectionStatus>;
|
||||
}
|
||||
|
||||
class CachedAnalyticsAPI {
|
||||
private readonly CACHE_TTL = {
|
||||
PLATFORM_STATUS: 30 * 60 * 1000, // 30 minutes - status changes rarely
|
||||
ANALYTICS_DATA: 60 * 60 * 1000, // 60 minutes - analytics data cached for 1 hour
|
||||
USER_SITES: 120 * 60 * 1000, // 120 minutes - user sites change very rarely
|
||||
};
|
||||
|
||||
/**
|
||||
* Get platform connection status with caching
|
||||
*/
|
||||
async getPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
|
||||
const endpoint = '/api/analytics/platforms';
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = analyticsCache.get<{ platforms: Record<string, PlatformConnectionStatus> }>(endpoint);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Platform status (cached for 30 minutes)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching platform status... (will cache for 30 minutes)');
|
||||
const response = await apiClient.get(endpoint);
|
||||
|
||||
// Cache the result with extended TTL
|
||||
analyticsCache.set(endpoint, undefined, response.data, this.CACHE_TTL.PLATFORM_STATUS);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data with caching
|
||||
*/
|
||||
async getAnalyticsData(platforms?: string[], bypassCache: boolean = false): Promise<AnalyticsResponse> {
|
||||
const baseParams: any = platforms ? { platforms: platforms.join(',') } : {};
|
||||
const endpoint = '/api/analytics/data';
|
||||
|
||||
// If bypassing cache, add timestamp to force fresh request
|
||||
const requestParams = bypassCache ? { ...baseParams, _t: Date.now() } : baseParams;
|
||||
|
||||
// Try to get from cache first (unless bypassing)
|
||||
if (!bypassCache) {
|
||||
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, baseParams);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)');
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching analytics data... (will cache for 60 minutes)', requestParams);
|
||||
const response = await apiClient.get(endpoint, { params: requestParams });
|
||||
|
||||
// Cache the result with extended TTL (unless bypassing)
|
||||
if (!bypassCache) {
|
||||
analyticsCache.set(endpoint, baseParams, response.data, this.CACHE_TTL.ANALYTICS_DATA);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate platform status cache
|
||||
*/
|
||||
invalidatePlatformStatus(): void {
|
||||
analyticsCache.invalidate('/api/analytics/platforms');
|
||||
console.log('🔄 Analytics Cache: Platform status invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate analytics data cache
|
||||
*/
|
||||
invalidateAnalyticsData(): void {
|
||||
analyticsCache.invalidate('/api/analytics/data');
|
||||
console.log('🔄 Analytics Cache: Analytics data invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all analytics cache
|
||||
*/
|
||||
invalidateAll(): void {
|
||||
analyticsCache.invalidate('analytics');
|
||||
console.log('🔄 Analytics Cache: All analytics cache invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh analytics data (bypass cache)
|
||||
*/
|
||||
async forceRefreshAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
// Try to clear backend cache first (but don't fail if it doesn't work)
|
||||
try {
|
||||
await this.clearBackendCache(platforms);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Backend cache clearing failed, continuing with frontend cache clear:', error);
|
||||
}
|
||||
|
||||
// Always invalidate frontend cache
|
||||
this.invalidateAnalyticsData();
|
||||
|
||||
// Finally get fresh data with cache bypass
|
||||
return this.getAnalyticsData(platforms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear backend analytics cache
|
||||
*/
|
||||
async clearBackendCache(platforms?: string[]): Promise<void> {
|
||||
try {
|
||||
if (platforms && platforms.length > 0) {
|
||||
// Clear cache for specific platforms
|
||||
for (const platform of platforms) {
|
||||
await apiClient.post('/api/analytics/cache/clear', null, {
|
||||
params: { platform }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Clear all cache
|
||||
await apiClient.post('/api/analytics/cache/clear');
|
||||
}
|
||||
console.log('🔄 Backend analytics cache cleared');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clear backend cache:', error);
|
||||
// Don't throw error, just log it - frontend cache clearing is more important
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh platform status (bypass cache)
|
||||
*/
|
||||
async forceRefreshPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
|
||||
this.invalidatePlatformStatus();
|
||||
return this.getPlatformStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging
|
||||
*/
|
||||
getCacheStats() {
|
||||
return analyticsCache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
analyticsCache.invalidate();
|
||||
console.log('🗑️ Analytics Cache: All cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data with database-first caching (most aggressive)
|
||||
* Use this when you know the data is stored in the database
|
||||
*/
|
||||
async getAnalyticsDataFromDB(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
const params = platforms ? { platforms: platforms.join(',') } : undefined;
|
||||
const endpoint = '/api/analytics/data';
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, params);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Analytics data from DB (cached for 2 hours)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching analytics data from DB... (will cache for 2 hours)', params);
|
||||
const response = await apiClient.get(endpoint, { params });
|
||||
|
||||
// Cache the result with database TTL (very long since it's from DB)
|
||||
analyticsCache.setDatabaseData(endpoint, params, response.data);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const cachedAnalyticsAPI = new CachedAnalyticsAPI();
|
||||
|
||||
export default cachedAnalyticsAPI;
|
||||
456
frontend/src/api/client.ts
Normal file
456
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Global subscription error handler - will be set by the app
|
||||
// Can be async to support subscription status refresh
|
||||
let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean | Promise<boolean>) => {
|
||||
globalSubscriptionErrorHandler = handler;
|
||||
};
|
||||
|
||||
// Export a function to trigger subscription error handler from outside axios interceptors
|
||||
export const triggerSubscriptionError = async (error: any) => {
|
||||
const status = error?.response?.status;
|
||||
console.log('triggerSubscriptionError: Received error', {
|
||||
hasHandler: !!globalSubscriptionErrorHandler,
|
||||
status,
|
||||
dataKeys: error?.response?.data ? Object.keys(error.response.data) : null
|
||||
});
|
||||
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
console.log('triggerSubscriptionError: Calling global subscription error handler');
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
// Handle both sync and async handlers
|
||||
return result instanceof Promise ? await result : result;
|
||||
}
|
||||
|
||||
console.warn('triggerSubscriptionError: No global subscription error handler registered');
|
||||
return false;
|
||||
};
|
||||
|
||||
// Optional token getter installed from within the app after Clerk is available
|
||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
// Optional Clerk sign-out function - set by App.tsx when Clerk is available
|
||||
let clerkSignOut: (() => Promise<void>) | null = null;
|
||||
|
||||
export const setClerkSignOut = (signOutFn: () => Promise<void>) => {
|
||||
clerkSignOut = signOutFn;
|
||||
};
|
||||
|
||||
export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
||||
authTokenGetter = getter;
|
||||
};
|
||||
|
||||
// Get API URL from environment variables
|
||||
export const getApiUrl = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In production, use the environment variable or fallback
|
||||
return process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL;
|
||||
}
|
||||
return ''; // Use proxy in development
|
||||
};
|
||||
|
||||
// Create a shared axios instance for all API calls
|
||||
const apiBaseUrl = getApiUrl();
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 60000, // Increased to 60 seconds for regular API calls
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for AI operations with extended timeout
|
||||
export const aiApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for long-running operations like SEO analysis
|
||||
export const longRunningApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 300000, // 5 minutes timeout for SEO analysis
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for polling operations with reasonable timeout
|
||||
export const pollingApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 60000, // 60 seconds timeout for polling status checks
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for logging (optional)
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
console.warn(`[apiClient] This usually means TokenInstaller hasn't run yet. Request will likely fail with 401.`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
console.warn(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[apiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
// non-fatal - let the request proceed, backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Custom error types for better error handling
|
||||
export class ConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
// Add response interceptor with automatic token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle network errors and timeouts (backend not available)
|
||||
if (!error.response) {
|
||||
// Network error, timeout, or backend not reachable
|
||||
const connectionError = new NetworkError(
|
||||
'Unable to connect to the backend server. Please check if the server is running.'
|
||||
);
|
||||
console.error('Network/Connection Error:', error.message || error);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// Handle server errors (5xx)
|
||||
if (error.response.status >= 500) {
|
||||
const connectionError = new ConnectionError(
|
||||
'Backend server is experiencing issues. Please try again later.'
|
||||
);
|
||||
console.error('Server Error:', error.response.status, error.response.data);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Get fresh token
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
// Update the request with new token
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
// Retry the request
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
// If retry failed, token is expired - sign out user and redirect to sign in
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization - allow InitialRouteHandler to work
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect to landing/sign-in
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
|
||||
// Clear any cached auth data
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
// Use Clerk signOut if available, otherwise just redirect
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => {
|
||||
// Redirect to landing page after sign out
|
||||
window.location.assign('/');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error during Clerk sign out:', err);
|
||||
// Fallback: redirect anyway
|
||||
window.location.assign('/');
|
||||
});
|
||||
} else {
|
||||
// Fallback: redirect to landing (will show sign-in if Clerk handles it)
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 errors that weren't retried (e.g., no authTokenGetter, already retried, etc.)
|
||||
if (error?.response?.status === 401 && (originalRequest._retry || !authTokenGetter)) {
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired (not retried), signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for AI client
|
||||
aiApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making AI ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[aiApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[aiApiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[aiApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[aiApiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[aiApiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
aiApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return aiApiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('AI API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('AI API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('AI API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for long-running client
|
||||
longRunningApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making long-running ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
longRunningApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('Long-running API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('Long-running API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Long-running API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for polling client
|
||||
pollingApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making polling ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
pollingApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (!wasHandled) {
|
||||
console.warn('Polling API Client: Subscription error not handled by global handler');
|
||||
}
|
||||
// Always reject so the polling hook can also handle it
|
||||
return Promise.reject(error);
|
||||
} else {
|
||||
console.warn('Polling API Client: No global subscription error handler registered');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Polling API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
177
frontend/src/api/componentLogic.ts
Normal file
177
frontend/src/api/componentLogic.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Component Logic API integration
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
// AI Research Interfaces
|
||||
export interface UserInfoRequest {
|
||||
full_name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UserInfoResponse {
|
||||
valid: boolean;
|
||||
user_info?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesRequest {
|
||||
research_depth: string;
|
||||
content_types: string[];
|
||||
auto_research: boolean;
|
||||
factual_content: boolean;
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesResponse {
|
||||
valid: boolean;
|
||||
preferences?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchRequest {
|
||||
topic: string;
|
||||
preferences: ResearchPreferencesRequest;
|
||||
}
|
||||
|
||||
export interface ResearchResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
results?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Personalization Interfaces
|
||||
export interface ContentStyleRequest {
|
||||
writing_style: string;
|
||||
tone: string;
|
||||
content_length: string;
|
||||
}
|
||||
|
||||
export interface ContentStyleResponse {
|
||||
valid: boolean;
|
||||
style_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BrandVoiceRequest {
|
||||
personality_traits: string[];
|
||||
voice_description?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface BrandVoiceResponse {
|
||||
valid: boolean;
|
||||
brand_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AdvancedSettingsRequest {
|
||||
seo_optimization: boolean;
|
||||
readability_level: string;
|
||||
content_structure: string[];
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsRequest {
|
||||
content_style: ContentStyleRequest;
|
||||
brand_voice: BrandVoiceRequest;
|
||||
advanced_settings: AdvancedSettingsRequest;
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsResponse {
|
||||
valid: boolean;
|
||||
settings?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Research Utilities Interfaces
|
||||
export interface ResearchTopicRequest {
|
||||
topic: string;
|
||||
api_keys: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResearchResultResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
// AI Research API Functions
|
||||
export async function validateUserInfo(request: UserInfoRequest): Promise<UserInfoResponse> {
|
||||
const res: AxiosResponse<UserInfoResponse> = await apiClient.post('/api/onboarding/ai-research/validate-user', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureResearchPreferences(request: ResearchPreferencesRequest): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.post('/api/onboarding/ai-research/configure-preferences', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchRequest(request: ResearchRequest): Promise<ResearchResponse> {
|
||||
const res: AxiosResponse<ResearchResponse> = await apiClient.post('/api/onboarding/ai-research/process-research', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/ai-research/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferences(): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.get('/api/onboarding/ai-research/preferences');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Personalization API Functions
|
||||
export async function validateContentStyle(request: ContentStyleRequest): Promise<ContentStyleResponse> {
|
||||
const res: AxiosResponse<ContentStyleResponse> = await apiClient.post('/api/onboarding/personalization/validate-style', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureBrandVoice(request: BrandVoiceRequest): Promise<BrandVoiceResponse> {
|
||||
const res: AxiosResponse<BrandVoiceResponse> = await apiClient.post('/api/onboarding/personalization/configure-brand', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processPersonalizationSettings(request: PersonalizationSettingsRequest): Promise<PersonalizationSettingsResponse> {
|
||||
const res: AxiosResponse<PersonalizationSettingsResponse> = await apiClient.post('/api/onboarding/personalization/process-settings', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getPersonalizationConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/personalization/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateContentGuidelines(settings: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/personalization/generate-guidelines', settings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Research Utilities API Functions
|
||||
export async function processResearchTopic(request: ResearchTopicRequest): Promise<ResearchResultResponse> {
|
||||
const res: AxiosResponse<ResearchResultResponse> = await apiClient.post('/api/onboarding/research/process-topic', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchResults(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/process-results', results);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateResearchRequest(topic: string, api_keys: Record<string, string>): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/validate-request', { topic, api_keys });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchProvidersInfo(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research/providers-info');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateResearchReport(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/generate-report', results);
|
||||
return res.data;
|
||||
}
|
||||
202
frontend/src/api/gsc.ts
Normal file
202
frontend/src/api/gsc.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/** Google Search Console API client for ALwrity frontend. */
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface GSCSite {
|
||||
siteUrl: string;
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
export interface GSCAnalyticsRequest {
|
||||
site_url: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface GSCAnalyticsResponse {
|
||||
rows: Array<{
|
||||
keys: string[];
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>;
|
||||
rowCount: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
export interface GSCSitemap {
|
||||
path: string;
|
||||
lastSubmitted: string;
|
||||
contents: Array<{
|
||||
type: string;
|
||||
submitted: string;
|
||||
indexed: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GSCStatusResponse {
|
||||
connected: boolean;
|
||||
sites?: GSCSite[];
|
||||
last_sync?: string;
|
||||
}
|
||||
|
||||
class GSCAPI {
|
||||
private baseUrl = '/gsc';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set the auth token getter function
|
||||
*/
|
||||
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||
this.getAuthToken = getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated API client with auth token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Search Console OAuth authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<{ auth_url: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/auth/url`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting OAuth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback (typically called from popup)
|
||||
*/
|
||||
async handleCallback(code: string, state: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/callback`, {
|
||||
params: { code, state }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error handling OAuth callback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's Google Search Console sites
|
||||
*/
|
||||
async getSites(): Promise<{ sites: GSCSite[] }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sites`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting sites:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search analytics data
|
||||
*/
|
||||
async getAnalytics(request: GSCAnalyticsRequest): Promise<GSCAnalyticsResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/analytics`, request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting analytics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sitemaps for a specific site
|
||||
*/
|
||||
async getSitemaps(siteUrl: string): Promise<{ sitemaps: GSCSitemap[] }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sitemaps/${encodeURIComponent(siteUrl)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting sitemaps:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GSC connection status
|
||||
*/
|
||||
async getStatus(): Promise<GSCStatusResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear incomplete GSC credentials
|
||||
*/
|
||||
async clearIncomplete(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/clear-incomplete`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error clearing incomplete credentials:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect GSC account
|
||||
*/
|
||||
async disconnect(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/disconnect`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error disconnecting account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gscAPI = new GSCAPI();
|
||||
211
frontend/src/api/intentResearchApi.ts
Normal file
211
frontend/src/api/intentResearchApi.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Intent-Driven Research API Client
|
||||
*
|
||||
* Client for the new intent-driven research endpoints:
|
||||
* - /api/research/intent/analyze - Analyze user intent
|
||||
* - /api/research/intent/research - Execute intent-driven research
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
AnalyzeIntentRequest,
|
||||
AnalyzeIntentResponse,
|
||||
IntentDrivenResearchRequest,
|
||||
IntentDrivenResearchResponse,
|
||||
} from '../components/Research/types/intent.types';
|
||||
|
||||
/**
|
||||
* Analyze user input to understand research intent.
|
||||
*
|
||||
* Uses AI to infer:
|
||||
* - What questions need answering
|
||||
* - What deliverables user expects (statistics, quotes, case studies)
|
||||
* - What depth and focus is appropriate
|
||||
*/
|
||||
export const analyzeIntent = async (
|
||||
request: AnalyzeIntentRequest
|
||||
): Promise<AnalyzeIntentResponse> => {
|
||||
try {
|
||||
const { data } = await apiClient.post<AnalyzeIntentResponse>(
|
||||
'/api/research/intent/analyze',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] analyzeIntent failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
intent: {
|
||||
primary_question: request.user_input,
|
||||
secondary_questions: [],
|
||||
purpose: 'learn',
|
||||
content_output: 'general',
|
||||
expected_deliverables: ['key_statistics'],
|
||||
depth: 'detailed',
|
||||
focus_areas: [],
|
||||
perspective: null,
|
||||
time_sensitivity: null,
|
||||
input_type: 'keywords',
|
||||
original_input: request.user_input,
|
||||
confidence: 0.5,
|
||||
needs_clarification: true,
|
||||
clarifying_questions: [],
|
||||
},
|
||||
analysis_summary: 'Failed to analyze intent',
|
||||
suggested_queries: [],
|
||||
suggested_keywords: [],
|
||||
suggested_angles: [],
|
||||
quick_options: [],
|
||||
error_message: error.message || 'Failed to analyze intent',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute research based on user intent.
|
||||
*
|
||||
* This is the main endpoint for intent-driven research. It:
|
||||
* 1. Uses the confirmed intent (or infers from user_input)
|
||||
* 2. Generates targeted queries for each expected deliverable
|
||||
* 3. Executes research using Exa/Tavily/Google
|
||||
* 4. Analyzes results through the lens of user intent
|
||||
* 5. Returns exactly what the user needs
|
||||
*/
|
||||
export const executeIntentResearch = async (
|
||||
request: IntentDrivenResearchRequest
|
||||
): Promise<IntentDrivenResearchResponse> => {
|
||||
try {
|
||||
const { data } = await apiClient.post<IntentDrivenResearchResponse>(
|
||||
'/api/research/intent/research',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] executeIntentResearch failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: error.message || 'Research failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined function to analyze intent and execute research in one call.
|
||||
*
|
||||
* For simple use cases where user doesn't need to confirm intent.
|
||||
*/
|
||||
export const quickIntentResearch = async (
|
||||
userInput: string,
|
||||
options?: {
|
||||
usePersona?: boolean;
|
||||
useCompetitorData?: boolean;
|
||||
maxSources?: number;
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
}
|
||||
): Promise<IntentDrivenResearchResponse> => {
|
||||
try {
|
||||
// First analyze intent
|
||||
const analyzeResponse = await analyzeIntent({
|
||||
user_input: userInput,
|
||||
keywords: userInput.split(' ').filter(k => k.length > 2),
|
||||
use_persona: options?.usePersona ?? true,
|
||||
use_competitor_data: options?.useCompetitorData ?? true,
|
||||
});
|
||||
|
||||
if (!analyzeResponse.success) {
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: analyzeResponse.error_message || 'Failed to analyze intent',
|
||||
};
|
||||
}
|
||||
|
||||
// Execute research with inferred intent
|
||||
return await executeIntentResearch({
|
||||
user_input: userInput,
|
||||
confirmed_intent: analyzeResponse.intent,
|
||||
selected_queries: analyzeResponse.suggested_queries.slice(0, 5), // Top 5 queries
|
||||
max_sources: options?.maxSources ?? 10,
|
||||
include_domains: options?.includeDomains ?? [],
|
||||
exclude_domains: options?.excludeDomains ?? [],
|
||||
skip_inference: true, // We already have intent
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] quickIntentResearch failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: error.message || 'Research failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const intentResearchApi = {
|
||||
analyzeIntent,
|
||||
executeIntentResearch,
|
||||
quickIntentResearch,
|
||||
};
|
||||
|
||||
export default intentResearchApi;
|
||||
181
frontend/src/api/oauthTokenMonitoring.ts
Normal file
181
frontend/src/api/oauthTokenMonitoring.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* OAuth Token Monitoring API Client
|
||||
* Functions for interacting with OAuth token monitoring endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface OAuthTokenStatus {
|
||||
connected: boolean;
|
||||
monitoring_task: {
|
||||
id: number | null;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
[platform: string]: OAuthTokenStatus;
|
||||
}
|
||||
|
||||
export interface OAuthTokenStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
platform_status: PlatformStatus;
|
||||
connected_platforms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManualRefreshResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
platform: string;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
execution_result: {
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
platform: string;
|
||||
execution_date: string;
|
||||
status: string;
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTasksResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
tasks_created: number;
|
||||
tasks: Array<{
|
||||
id: number;
|
||||
platform: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token monitoring status for all platforms
|
||||
*/
|
||||
export const getOAuthTokenStatus = async (userId: string): Promise<OAuthTokenStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<OAuthTokenStatusResponse>(`/api/oauth-tokens/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching OAuth token status:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch OAuth token status'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually trigger token refresh for a specific platform
|
||||
*/
|
||||
export const manualRefreshToken = async (
|
||||
userId: string,
|
||||
platform: string
|
||||
): Promise<ManualRefreshResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<ManualRefreshResponse>(
|
||||
`/api/oauth-tokens/refresh/${userId}/${platform}`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error manually refreshing token:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to refresh token'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for OAuth token monitoring
|
||||
*/
|
||||
export const getOAuthTokenExecutionLogs = async (
|
||||
userId: string,
|
||||
platform?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (platform) {
|
||||
params.platform = platform;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>(
|
||||
`/api/oauth-tokens/execution-logs/${userId}`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create OAuth token monitoring tasks
|
||||
*/
|
||||
export const createOAuthMonitoringTasks = async (
|
||||
userId: string,
|
||||
platforms?: string[]
|
||||
): Promise<CreateTasksResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<CreateTasksResponse>(
|
||||
`/api/oauth-tokens/create-tasks/${userId}`,
|
||||
platforms ? { platforms } : {}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating monitoring tasks:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to create monitoring tasks'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
214
frontend/src/api/onboarding.ts
Normal file
214
frontend/src/api/onboarding.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Make sure to install axios: npm install axios
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface APIKeyRequest {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface APIKeyResponse {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStepResponse {
|
||||
step: number;
|
||||
data?: any;
|
||||
validation_errors?: string[];
|
||||
}
|
||||
|
||||
export interface OnboardingSessionResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface OnboardingProgressResponse {
|
||||
progress: number;
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
completion_percentage: number;
|
||||
}
|
||||
|
||||
export async function startOnboarding() {
|
||||
const res: AxiosResponse<OnboardingSessionResponse> = await apiClient.post('/api/onboarding/start');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCurrentStep() {
|
||||
// Get the current step from the onboarding status
|
||||
console.log('getCurrentStep: Calling /api/onboarding/status');
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/status');
|
||||
console.log('getCurrentStep: Backend returned:', res.data);
|
||||
return { step: res.data.current_step || 1 };
|
||||
}
|
||||
|
||||
export async function setCurrentStep(step: number, stepData?: any) {
|
||||
// Complete the current step to move to the next one
|
||||
console.log('setCurrentStep: Completing step', step, 'with data:', stepData);
|
||||
const res: AxiosResponse<OnboardingStepResponse> = await apiClient.post(`/api/onboarding/step/${step}/complete`, {
|
||||
data: stepData || {},
|
||||
validation_errors: []
|
||||
});
|
||||
console.log('setCurrentStep: Backend response:', res.data);
|
||||
return { step };
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeys: Starting API call to /api/onboarding/api-keys');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeys: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<Record<string, string>> = await apiClient.get('/api/onboarding/api-keys');
|
||||
console.log('getApiKeys: API call successful');
|
||||
return res.data;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeys: Attempt ${attempt + 1} failed:`, error.response?.status, error.message);
|
||||
|
||||
// If it's a rate limit error (429), wait and retry
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response?.data?.retry_after || 60;
|
||||
const delay = Math.min(retryAfter * 1000, 5000); // Max 5 seconds
|
||||
|
||||
console.log(`getApiKeys: Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, don't retry
|
||||
console.log('getApiKeys: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeys: All retries exhausted');
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function getApiKeysForOnboarding() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeysForOnboarding: Starting API call to /api/onboarding/api-keys/onboarding');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/api-keys/onboarding');
|
||||
console.log('getApiKeysForOnboarding: API call successful');
|
||||
return res.data.api_keys || {};
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1} failed:`, error.response?.status, error.message);
|
||||
|
||||
// If it's a rate limit error (429), wait and retry
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response?.data?.retry_after || 60;
|
||||
const delay = Math.min(retryAfter * 1000, 5000); // Max 5 seconds
|
||||
|
||||
console.log(`getApiKeysForOnboarding: Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, don't retry
|
||||
console.log('getApiKeysForOnboarding: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeysForOnboarding: All retries exhausted');
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function saveApiKey(provider: string, api_key: string, description?: string) {
|
||||
const res: AxiosResponse<APIKeyResponse> = await apiClient.post('/api/onboarding/api-keys', {
|
||||
provider,
|
||||
api_key,
|
||||
description
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getProgress() {
|
||||
const res: AxiosResponse<OnboardingProgressResponse> = await apiClient.get('/api/onboarding/progress');
|
||||
return { progress: res.data.completion_percentage || 0 };
|
||||
}
|
||||
|
||||
export async function setProgress(progress: number) {
|
||||
// Progress is managed automatically by the backend
|
||||
// This function is kept for compatibility but doesn't make a backend call
|
||||
return { progress };
|
||||
}
|
||||
|
||||
// Additional functions for better integration
|
||||
export async function getOnboardingConfig() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/config');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStepData(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.get(`/api/onboarding/step/${stepNumber}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStep1ApiKeysFromProgress(): Promise<{ gemini?: string; exa?: string; copilotkit?: string }> {
|
||||
try {
|
||||
const step = await getStepData(1);
|
||||
const keys = step?.data?.api_keys || {};
|
||||
return {
|
||||
gemini: keys.gemini || undefined,
|
||||
exa: keys.exa || undefined,
|
||||
copilotkit: keys.copilotkit || undefined,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipStep(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.post(`/api/onboarding/step/${stepNumber}/skip`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateApiKeys() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/api-keys/validate');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function completeOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/complete');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function resetOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/reset');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// New functions for FinalStep data loading
|
||||
export async function getOnboardingSummary() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/summary');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getWebsiteAnalysisData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/website-analysis');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferencesData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research-preferences');
|
||||
return res.data;
|
||||
}
|
||||
306
frontend/src/api/persona.ts
Normal file
306
frontend/src/api/persona.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Persona API client for frontend
|
||||
* Handles writing persona generation and management
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface PersonaGenerationRequest {
|
||||
onboarding_session_id?: number;
|
||||
force_regenerate?: boolean;
|
||||
}
|
||||
|
||||
export interface PersonaResponse {
|
||||
persona_id: number;
|
||||
persona_name: string;
|
||||
archetype: string;
|
||||
core_belief: string;
|
||||
confidence_score: number;
|
||||
platforms: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PersonaGenerationResponse {
|
||||
success: boolean;
|
||||
persona_id?: number;
|
||||
message: string;
|
||||
confidence_score?: number;
|
||||
data_sufficiency?: number;
|
||||
platforms_generated?: string[];
|
||||
}
|
||||
|
||||
export interface PersonaReadinessResponse {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
missing_steps: string[];
|
||||
data_sufficiency: number;
|
||||
recommendations?: string[];
|
||||
}
|
||||
|
||||
export interface PersonaPreviewResponse {
|
||||
preview: {
|
||||
identity: {
|
||||
persona_name: string;
|
||||
archetype: string;
|
||||
core_belief: string;
|
||||
brand_voice_description: string;
|
||||
};
|
||||
linguistic_fingerprint: any;
|
||||
tonal_range: any;
|
||||
sample_platform: {
|
||||
platform: string;
|
||||
adaptation: any;
|
||||
};
|
||||
};
|
||||
confidence_score: number;
|
||||
data_sufficiency: number;
|
||||
}
|
||||
|
||||
export interface PlatformInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
character_limit?: number;
|
||||
optimal_length?: string;
|
||||
word_count?: string;
|
||||
seo_optimized?: boolean;
|
||||
storytelling_focus?: boolean;
|
||||
subscription_focus?: boolean;
|
||||
}
|
||||
|
||||
export interface SupportedPlatformsResponse {
|
||||
platforms: PlatformInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has sufficient onboarding data for persona generation
|
||||
*/
|
||||
export const checkPersonaReadiness = async (userId: number = 1): Promise<PersonaReadinessResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/persona-readiness', {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking persona readiness:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to check persona readiness');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a preview of the writing persona without saving
|
||||
*/
|
||||
export const generatePersonaPreview = async (userId: number = 1): Promise<PersonaPreviewResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/persona-preview', {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating persona preview:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate persona preview');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and save a writing persona from onboarding data
|
||||
*/
|
||||
export const generateWritingPersona = async (userId: number = 1, request: PersonaGenerationRequest = {}): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/personas/generate', request, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating writing persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate writing persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all writing personas for a user
|
||||
* Note: user_id is extracted from Clerk JWT token, no need to pass it
|
||||
*/
|
||||
export const getUserPersonas = async (): Promise<{ personas: PersonaResponse[]; total_count: number }> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/personas/user');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting user personas:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get user personas');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific persona
|
||||
*/
|
||||
export const getPersonaDetails = async (userId: number, personaId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/${personaId}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting persona details:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get persona details');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get persona adaptation for a specific platform
|
||||
* Note: user_id is extracted from Clerk JWT token, no need to pass it
|
||||
*/
|
||||
export const getPlatformPersona = async (platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/platform/${platform}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting platform persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get platform persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of supported platforms
|
||||
*/
|
||||
export const getSupportedPlatforms = async (): Promise<SupportedPlatformsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/personas/platforms');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting supported platforms:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get supported platforms');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing persona
|
||||
*/
|
||||
export const updatePersona = async (userId: number, personaId: number, updateData: any): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.put(`/api/personas/${personaId}`, updateData, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error updating persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to update persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update platform-specific persona
|
||||
* Note: user_id is extracted from Clerk JWT token
|
||||
*/
|
||||
export const updatePlatformPersona = async (platform: string, updateData: any): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.put(`/api/personas/platform/${platform}`, updateData);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error updating platform persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to update platform persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a platform-specific persona from core persona
|
||||
* Note: user_id is extracted from Clerk JWT token
|
||||
*/
|
||||
export const generatePlatformPersona = async (platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/personas/generate-platform/${platform}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating ${platform} persona:`, error);
|
||||
throw new Error(error.response?.data?.detail || `Failed to generate ${platform} persona`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if Facebook persona exists for user
|
||||
* Note: user_id is extracted from Clerk JWT token or passed as parameter
|
||||
*/
|
||||
export const checkFacebookPersona = async (userId?: string): Promise<{
|
||||
has_persona: boolean;
|
||||
has_core_persona: boolean;
|
||||
persona: any;
|
||||
onboarding_completed: boolean;
|
||||
}> => {
|
||||
try {
|
||||
// Get user_id from parameter or localStorage
|
||||
const user_id = userId || localStorage.getItem('user_id');
|
||||
if (!user_id) {
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/api/personas/facebook-persona/check/${user_id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking Facebook persona:', error);
|
||||
// Return safe defaults on error
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a persona
|
||||
*/
|
||||
export const deletePersona = async (userId: number, personaId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/api/personas/${personaId}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to delete persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate content using persona replication engine
|
||||
*/
|
||||
export const generateContentWithPersona = async (
|
||||
userId: number,
|
||||
platform: string,
|
||||
contentRequest: string,
|
||||
contentType: string = 'post'
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/personas/generate-content', {
|
||||
user_id: userId,
|
||||
platform,
|
||||
content_request: contentRequest,
|
||||
content_type: contentType
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating content with persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate content with persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export hardened persona prompt for external use
|
||||
*/
|
||||
export const exportPersonaPrompt = async (userId: number, platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/export/${platform}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error exporting persona prompt:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to export persona prompt');
|
||||
}
|
||||
};
|
||||
158
frontend/src/api/personaApi.ts
Normal file
158
frontend/src/api/personaApi.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Persona API Client
|
||||
* Handles communication with the persona generation backend services.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface PersonaGenerationRequest {
|
||||
onboarding_data: {
|
||||
websiteAnalysis?: any;
|
||||
competitorResearch?: any;
|
||||
sitemapAnalysis?: any;
|
||||
businessData?: any;
|
||||
};
|
||||
selected_platforms: string[];
|
||||
user_preferences?: any;
|
||||
}
|
||||
|
||||
export interface PersonaGenerationResponse {
|
||||
success: boolean;
|
||||
core_persona?: any;
|
||||
platform_personas?: Record<string, any>;
|
||||
quality_metrics?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PersonaQualityRequest {
|
||||
core_persona: any;
|
||||
platform_personas: Record<string, any>;
|
||||
user_feedback?: any;
|
||||
}
|
||||
|
||||
export interface PersonaQualityResponse {
|
||||
success: boolean;
|
||||
quality_metrics?: any;
|
||||
recommendations?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PersonaOptions {
|
||||
success: boolean;
|
||||
available_platforms: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
persona_types: string[];
|
||||
quality_metrics: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI writing personas using the sophisticated persona system.
|
||||
*/
|
||||
export const generateWritingPersonas = async (
|
||||
request: PersonaGenerationRequest
|
||||
): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/generate-personas', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating personas:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to generate personas'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assess the quality of generated personas.
|
||||
*/
|
||||
export const assessPersonaQuality = async (
|
||||
request: PersonaQualityRequest
|
||||
): Promise<PersonaQualityResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/assess-quality', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error assessing persona quality:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to assess persona quality'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Regenerate persona with different parameters.
|
||||
*/
|
||||
export const regeneratePersona = async (
|
||||
request: PersonaGenerationRequest
|
||||
): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/regenerate-persona', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error regenerating persona:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to regenerate persona'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available options for persona generation.
|
||||
*/
|
||||
export const getPersonaGenerationOptions = async (): Promise<PersonaOptions> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/step4/persona-options');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting persona options:', error);
|
||||
return {
|
||||
success: false,
|
||||
available_platforms: [],
|
||||
persona_types: [],
|
||||
quality_metrics: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to prepare onboarding data for persona generation.
|
||||
*/
|
||||
export const prepareOnboardingData = (stepData: any) => {
|
||||
return {
|
||||
websiteAnalysis: stepData?.analysis || null,
|
||||
competitorResearch: {
|
||||
competitors: stepData?.competitors || [],
|
||||
researchSummary: stepData?.researchSummary || null,
|
||||
socialMediaAccounts: stepData?.socialMediaAccounts || {}
|
||||
},
|
||||
sitemapAnalysis: stepData?.sitemapAnalysis || null,
|
||||
businessData: stepData?.businessData || null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to validate persona generation request.
|
||||
*/
|
||||
export const validatePersonaRequest = (request: PersonaGenerationRequest): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!request.onboarding_data) {
|
||||
errors.push('Onboarding data is required');
|
||||
}
|
||||
|
||||
if (!request.selected_platforms || request.selected_platforms.length === 0) {
|
||||
errors.push('At least one platform must be selected');
|
||||
}
|
||||
|
||||
if (request.selected_platforms && request.selected_platforms.length > 5) {
|
||||
errors.push('Maximum 5 platforms can be selected');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Platform Insights Monitoring API Client
|
||||
* Provides typed functions for fetching platform insights (GSC/Bing) monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface PlatformInsightsTask {
|
||||
id: number;
|
||||
platform: 'gsc' | 'bing';
|
||||
site_url: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsStatusResponse {
|
||||
success: boolean;
|
||||
user_id: string;
|
||||
gsc_tasks: PlatformInsightsTask[];
|
||||
bing_tasks: PlatformInsightsTask[];
|
||||
total_tasks: number;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
data_source: 'cached' | 'api' | 'onboarding' | 'storage' | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsLogsResponse {
|
||||
success: boolean;
|
||||
logs: PlatformInsightsExecutionLog[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform insights status for a user
|
||||
*/
|
||||
export const getPlatformInsightsStatus = async (
|
||||
userId: string
|
||||
): Promise<PlatformInsightsStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for platform insights tasks
|
||||
*/
|
||||
export const getPlatformInsightsLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
taskId?: number
|
||||
): Promise<PlatformInsightsLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights logs');
|
||||
}
|
||||
};
|
||||
|
||||
359
frontend/src/api/researchConfig.ts
Normal file
359
frontend/src/api/researchConfig.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Research Configuration API
|
||||
* Fetches provider availability and persona-aware defaults
|
||||
*/
|
||||
|
||||
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ProviderAvailability {
|
||||
google_available: boolean;
|
||||
exa_available: boolean;
|
||||
tavily_available: boolean;
|
||||
gemini_key_status: 'configured' | 'missing';
|
||||
exa_key_status: 'configured' | 'missing';
|
||||
tavily_key_status: 'configured' | 'missing';
|
||||
}
|
||||
|
||||
export interface PersonaDefaults {
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
suggested_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
has_research_persona?: boolean; // Phase 2: Indicates if research persona exists
|
||||
|
||||
// Phase 2: Additional fields for pre-filling advanced options
|
||||
default_research_mode?: string; // basic, comprehensive, targeted
|
||||
default_provider?: string; // exa, tavily, google
|
||||
suggested_keywords?: string[]; // For keyword suggestions
|
||||
research_angles?: string[]; // Alternative research focuses
|
||||
|
||||
// Phase 2+: Enhanced provider-specific defaults from research persona
|
||||
suggested_exa_search_type?: string; // auto, neural, keyword, fast, deep
|
||||
suggested_tavily_topic?: string; // general, news, finance
|
||||
suggested_tavily_search_depth?: string; // basic, advanced, fast, ultra-fast
|
||||
suggested_tavily_include_answer?: string; // false, basic, advanced
|
||||
suggested_tavily_time_range?: string; // day, week, month, year
|
||||
suggested_tavily_raw_content_format?: string; // false, markdown, text
|
||||
provider_recommendations?: Record<string, string>; // Use case -> provider mapping
|
||||
}
|
||||
|
||||
export interface ResearchPreset {
|
||||
name: string;
|
||||
keywords: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
research_mode: ResearchMode;
|
||||
config: any; // ResearchConfig
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ResearchPersona {
|
||||
default_industry: string;
|
||||
default_target_audience: string;
|
||||
default_research_mode: ResearchMode;
|
||||
default_provider: ResearchProvider;
|
||||
suggested_keywords: string[];
|
||||
keyword_expansion_patterns: Record<string, string[]>;
|
||||
suggested_exa_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
suggested_exa_search_type?: string;
|
||||
suggested_tavily_topic?: string;
|
||||
suggested_tavily_search_depth?: string;
|
||||
suggested_tavily_include_answer?: string;
|
||||
suggested_tavily_time_range?: string;
|
||||
suggested_tavily_raw_content_format?: string;
|
||||
provider_recommendations?: Record<string, string>;
|
||||
research_angles: string[];
|
||||
query_enhancement_rules: Record<string, string>;
|
||||
recommended_presets: ResearchPreset[];
|
||||
research_preferences: Record<string, any>;
|
||||
generated_at?: string;
|
||||
confidence_score?: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ResearchConfigResponse {
|
||||
provider_availability: ProviderAvailability;
|
||||
persona_defaults: PersonaDefaults;
|
||||
research_persona?: ResearchPersona;
|
||||
onboarding_completed?: boolean;
|
||||
persona_scheduled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider availability status
|
||||
*/
|
||||
export const getProviderAvailability = async (): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/providers/status');
|
||||
const data = response.data || {};
|
||||
return {
|
||||
google_available: !!data.google?.available,
|
||||
exa_available: !!data.exa?.available,
|
||||
tavily_available: !!data.tavily?.available,
|
||||
gemini_key_status: data.google?.available ? 'configured' : 'missing',
|
||||
exa_key_status: data.exa?.available ? 'configured' : 'missing',
|
||||
tavily_key_status: data.tavily?.available ? 'configured' : 'missing',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting provider availability:', error);
|
||||
throw new Error(`Failed to get provider availability: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get persona-aware research defaults
|
||||
*/
|
||||
export const getPersonaDefaults = async (): Promise<PersonaDefaults> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/persona-defaults');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting persona defaults:', error);
|
||||
throw new Error(`Failed to get persona defaults: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Request deduplication: cache in-flight requests to prevent duplicate API calls
|
||||
let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
|
||||
|
||||
/**
|
||||
* Get complete research configuration
|
||||
*
|
||||
* Uses request deduplication: if multiple components call this simultaneously,
|
||||
* they will share the same promise to prevent duplicate API calls.
|
||||
*
|
||||
* Fetches complete configuration including provider availability, persona defaults,
|
||||
* and research persona from the unified /api/research/config endpoint.
|
||||
*/
|
||||
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
||||
// If a request is already in flight, return the same promise
|
||||
if (pendingConfigRequest) {
|
||||
console.log('[researchConfig] Reusing pending request to avoid duplicate API call');
|
||||
return pendingConfigRequest;
|
||||
}
|
||||
|
||||
// Create new request and cache it
|
||||
pendingConfigRequest = (async () => {
|
||||
try {
|
||||
// Use the unified /api/research/config endpoint which returns everything
|
||||
const response = await apiClient.get('/api/research/config');
|
||||
const config: ResearchConfigResponse = response.data;
|
||||
|
||||
console.log('[researchConfig] Config loaded:', {
|
||||
providers: {
|
||||
exa: config.provider_availability?.exa_available,
|
||||
tavily: config.provider_availability?.tavily_available,
|
||||
google: config.provider_availability?.google_available,
|
||||
},
|
||||
personaDefaults: {
|
||||
industry: config.persona_defaults?.industry,
|
||||
target_audience: config.persona_defaults?.target_audience,
|
||||
hasDomains: config.persona_defaults?.suggested_domains?.length > 0,
|
||||
hasResearchPersona: config.persona_defaults?.has_research_persona,
|
||||
},
|
||||
researchPersona: {
|
||||
exists: !!config.research_persona,
|
||||
hasPresets: !!config.research_persona?.recommended_presets?.length,
|
||||
},
|
||||
onboarding: {
|
||||
completed: config.onboarding_completed,
|
||||
personaScheduled: config.persona_scheduled,
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[researchConfig] Error getting research config:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error
|
||||
});
|
||||
|
||||
// Fallback: Try separate endpoints if unified endpoint fails
|
||||
try {
|
||||
console.log('[researchConfig] Falling back to separate endpoints');
|
||||
const [providersResp, personaDefaultsResp] = await Promise.allSettled([
|
||||
getProviderAvailability(),
|
||||
getPersonaDefaults(),
|
||||
]);
|
||||
|
||||
const providerAvailability: ProviderAvailability = providersResp.status === 'fulfilled'
|
||||
? providersResp.value
|
||||
: {
|
||||
google_available: true,
|
||||
exa_available: false,
|
||||
tavily_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing',
|
||||
tavily_key_status: 'missing',
|
||||
};
|
||||
|
||||
const personaDefaults: PersonaDefaults = personaDefaultsResp.status === 'fulfilled'
|
||||
? personaDefaultsResp.value
|
||||
: {
|
||||
industry: 'Technology',
|
||||
target_audience: 'Professionals',
|
||||
suggested_domains: [],
|
||||
has_research_persona: false,
|
||||
};
|
||||
|
||||
return {
|
||||
provider_availability: providerAvailability,
|
||||
persona_defaults: personaDefaults,
|
||||
research_persona: undefined,
|
||||
onboarding_completed: false,
|
||||
persona_scheduled: false,
|
||||
};
|
||||
} catch (fallbackError: any) {
|
||||
// Provide more specific error messages based on status code
|
||||
if (statusCode === 500) {
|
||||
throw new Error(`Backend server error: ${errorMessage}. Please check backend logs or try again later.`);
|
||||
} else if (statusCode === 401) {
|
||||
throw new Error('Authentication required. Please sign in again.');
|
||||
} else if (statusCode === 403) {
|
||||
throw new Error('Access denied. Please check your permissions.');
|
||||
} else if (statusCode === 429) {
|
||||
throw new Error('Rate limit exceeded. Please try again later.');
|
||||
} else if (!statusCode && error?.message) {
|
||||
// Network error or other connection issue
|
||||
throw new Error(`Failed to connect to server: ${error.message}`);
|
||||
} else {
|
||||
throw new Error(`Failed to get research config: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clear the cached request after completion (success or error)
|
||||
pendingConfigRequest = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pendingConfigRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or refresh research persona
|
||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||
*/
|
||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||
try {
|
||||
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error refreshing research persona:', error?.response?.status || error?.message);
|
||||
// Preserve the original error so subscription errors can be detected
|
||||
// The apiClient interceptor should handle 429 errors, but we preserve the error structure
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Competitor Analysis Response Interface
|
||||
*/
|
||||
export interface CompetitorAnalysisResponse {
|
||||
success: boolean;
|
||||
competitors?: Array<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
domain?: string;
|
||||
description?: string;
|
||||
similarity_score?: number;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
social_media_accounts?: Record<string, string>;
|
||||
social_media_citations?: Array<{
|
||||
platform?: string;
|
||||
account?: string;
|
||||
url?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
research_summary?: {
|
||||
total_competitors?: number;
|
||||
industry_insights?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
analysis_timestamp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis data from onboarding
|
||||
*/
|
||||
export const getCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||
console.log('[getCompetitorAnalysis] ===== START: Fetching competitor analysis =====');
|
||||
try {
|
||||
console.log('[getCompetitorAnalysis] Making GET request to /api/research/competitor-analysis');
|
||||
const response = await apiClient.get('/api/research/competitor-analysis');
|
||||
console.log('[getCompetitorAnalysis] ✅ Response received:', {
|
||||
success: response.data?.success,
|
||||
competitorsCount: response.data?.competitors?.length || 0,
|
||||
error: response.data?.error,
|
||||
fullResponse: response.data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[getCompetitorAnalysis] ❌ ERROR:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error,
|
||||
responseData: error?.response?.data
|
||||
});
|
||||
|
||||
// Return error response instead of throwing
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
console.log('[getCompetitorAnalysis] Returning error response:', errorResponse);
|
||||
return errorResponse;
|
||||
} finally {
|
||||
console.log('[getCompetitorAnalysis] ===== END: Fetching competitor analysis =====');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh competitor analysis by re-running competitor discovery
|
||||
*/
|
||||
export const refreshCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||
console.log('[refreshCompetitorAnalysis] ===== START: Refreshing competitor analysis =====');
|
||||
try {
|
||||
console.log('[refreshCompetitorAnalysis] Making POST request to /api/research/competitor-analysis/refresh');
|
||||
const response = await apiClient.post('/api/research/competitor-analysis/refresh');
|
||||
console.log('[refreshCompetitorAnalysis] ✅ Response received:', {
|
||||
success: response.data?.success,
|
||||
competitorsCount: response.data?.competitors?.length || 0,
|
||||
error: response.data?.error,
|
||||
fullResponse: response.data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[refreshCompetitorAnalysis] ❌ ERROR:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error,
|
||||
responseData: error?.response?.data
|
||||
});
|
||||
|
||||
// Return error response instead of throwing
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
console.log('[refreshCompetitorAnalysis] Returning error response:', errorResponse);
|
||||
return errorResponse;
|
||||
} finally {
|
||||
console.log('[refreshCompetitorAnalysis] ===== END: Refreshing competitor analysis =====');
|
||||
}
|
||||
};
|
||||
305
frontend/src/api/schedulerDashboard.ts
Normal file
305
frontend/src/api/schedulerDashboard.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Scheduler Dashboard API Client
|
||||
* Provides typed functions for fetching scheduler dashboard data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces for scheduler dashboard data
|
||||
export interface SchedulerStats {
|
||||
total_checks: number;
|
||||
tasks_found: number;
|
||||
tasks_executed: number;
|
||||
tasks_failed: number;
|
||||
tasks_skipped: number;
|
||||
last_check: string | null;
|
||||
last_update: string | null;
|
||||
active_executions: number;
|
||||
running: boolean;
|
||||
check_interval_minutes: number;
|
||||
min_check_interval_minutes: number;
|
||||
max_check_interval_minutes: number;
|
||||
intelligent_scheduling: boolean;
|
||||
active_strategies_count: number;
|
||||
last_interval_adjustment: string | null;
|
||||
registered_types: string[];
|
||||
// Cumulative/historical values from database
|
||||
cumulative_total_check_cycles: number;
|
||||
cumulative_tasks_found: number;
|
||||
cumulative_tasks_executed: number;
|
||||
cumulative_tasks_failed: number;
|
||||
}
|
||||
|
||||
export interface SchedulerJob {
|
||||
id: string;
|
||||
trigger_type: string;
|
||||
next_run_time: string | null;
|
||||
user_id: string | null;
|
||||
job_store: string;
|
||||
user_job_store: string;
|
||||
function_name?: string | null;
|
||||
platform?: string; // For OAuth token monitoring tasks and platform insights
|
||||
task_id?: number; // For OAuth token monitoring tasks, website analysis, and platform insights
|
||||
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
||||
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
||||
task_type?: string; // For website analysis tasks ('user_website' or 'competitor')
|
||||
task_category?: string; // 'website_analysis', 'platform_insights', 'oauth_token_monitoring'
|
||||
website_url?: string | null; // For website analysis tasks
|
||||
competitor_id?: number | null; // For competitor website analysis tasks
|
||||
}
|
||||
|
||||
export interface UserIsolation {
|
||||
enabled: boolean;
|
||||
current_user_id: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerDashboardData {
|
||||
stats: SchedulerStats;
|
||||
jobs: SchedulerJob[];
|
||||
job_count: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
user_isolation: UserIsolation;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface TaskFailurePattern {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
}
|
||||
|
||||
export interface TaskNeedingIntervention {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: TaskFailurePattern;
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}
|
||||
|
||||
export interface TaskInfo {
|
||||
id: number;
|
||||
task_title: string;
|
||||
component_name: string;
|
||||
metric: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number | null;
|
||||
user_id: number | string | null;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
created_at: string;
|
||||
task?: TaskInfo;
|
||||
is_scheduler_log?: boolean; // Flag for scheduler logs vs execution logs
|
||||
event_type?: string;
|
||||
job_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
is_scheduler_logs?: boolean; // Flag to indicate if these are scheduler logs
|
||||
}
|
||||
|
||||
export interface SchedulerJobsResponse {
|
||||
jobs: SchedulerJob[];
|
||||
total_jobs: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
}
|
||||
|
||||
export interface SchedulerEvent {
|
||||
id: number;
|
||||
event_type: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed';
|
||||
event_date: string | null;
|
||||
check_cycle_number: number | null;
|
||||
check_interval_minutes: number | null;
|
||||
previous_interval_minutes: number | null;
|
||||
new_interval_minutes: number | null;
|
||||
tasks_found: number | null;
|
||||
tasks_executed: number | null;
|
||||
tasks_failed: number | null;
|
||||
tasks_by_type: Record<string, number> | null;
|
||||
check_duration_seconds: number | null;
|
||||
active_strategies_count: number | null;
|
||||
active_executions: number | null;
|
||||
job_id: string | null;
|
||||
job_type: string | null;
|
||||
user_id: string | null;
|
||||
event_data: any;
|
||||
error_message: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerEventHistoryResponse {
|
||||
events: SchedulerEvent[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
date_filter?: {
|
||||
days: number;
|
||||
cutoff_date: string;
|
||||
showing_events_since: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler dashboard statistics and current state.
|
||||
*/
|
||||
export const getSchedulerDashboard = async (): Promise<SchedulerDashboardData> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerDashboardData>('/api/scheduler/dashboard');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler dashboard:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler dashboard'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get task execution logs from database.
|
||||
*
|
||||
* @param limit - Number of logs to return (1-500, default: 50)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param status - Filter by status (success, failed, running, skipped)
|
||||
*/
|
||||
export const getExecutionLogs = async (
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
status?: 'success' | 'failed' | 'running' | 'skipped'
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (status) {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/execution-logs', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed information about all scheduled jobs.
|
||||
*/
|
||||
export const getSchedulerJobs = async (): Promise<SchedulerJobsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerJobsResponse>('/api/scheduler/jobs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler jobs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler jobs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler event history from database.
|
||||
*
|
||||
* @param limit - Number of events to return (1-500, default: 5 for initial load, expand to 50 on hover)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
||||
* @param days - Number of days to look back (1-90, default: 7 days)
|
||||
*/
|
||||
export const getSchedulerEventHistory = async (
|
||||
limit: number = 5,
|
||||
offset: number = 0,
|
||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed',
|
||||
days: number = 7
|
||||
): Promise<SchedulerEventHistoryResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset, days };
|
||||
if (eventType) {
|
||||
params.event_type = eventType;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<SchedulerEventHistoryResponse>('/api/scheduler/event-history', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler event history:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler event history'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recent scheduler logs (restoration, job scheduling, etc.) formatted as execution logs.
|
||||
* These are shown in Execution Logs section when actual execution logs are not available.
|
||||
* Returns only the latest 5 logs (rolling window).
|
||||
*/
|
||||
export const getRecentSchedulerLogs = async (): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/recent-scheduler-logs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching recent scheduler logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch recent scheduler logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tasks that require manual intervention for a user.
|
||||
*/
|
||||
export const getTasksNeedingIntervention = async (userId: string): Promise<TaskNeedingIntervention[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: TaskNeedingIntervention[];
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to fetch tasks needing intervention');
|
||||
}
|
||||
|
||||
return response.data.tasks || [];
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tasks needing intervention:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch tasks needing intervention'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
85
frontend/src/api/seoAnalysis.ts
Normal file
85
frontend/src/api/seoAnalysis.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { longRunningApiClient } from './client';
|
||||
import { SEOAnalysisData } from '../components/shared/types';
|
||||
|
||||
// SEO Analysis API functions
|
||||
export const seoAnalysisAPI = {
|
||||
async analyzeURL(url: string, targetKeywords?: string[]): Promise<SEOAnalysisData | null> {
|
||||
try {
|
||||
console.log(`Starting SEO analysis for URL: ${url}`);
|
||||
console.log(`Target keywords:`, targetKeywords);
|
||||
|
||||
const requestData = {
|
||||
url,
|
||||
target_keywords: targetKeywords
|
||||
};
|
||||
console.log('Request data:', requestData);
|
||||
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/analyze-comprehensive', requestData);
|
||||
console.log('Response received:', response);
|
||||
console.log('Response data:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`SEO analysis completed for ${url}`);
|
||||
console.log('Analysis result:', response.data);
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('Analysis failed:', response.data.message);
|
||||
throw new Error(response.data.message || 'Analysis failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error analyzing URL:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getDetailedMetrics(url: string): Promise<any> {
|
||||
try {
|
||||
console.log(`Getting detailed metrics for URL: ${url}`);
|
||||
const response = await longRunningApiClient.get(`/api/seo-dashboard/metrics/${encodeURIComponent(url)}`);
|
||||
console.log(`Detailed metrics retrieved for ${url}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting detailed metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getAnalysisSummary(): Promise<any> {
|
||||
try {
|
||||
console.log('Getting analysis summary');
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/summary');
|
||||
console.log('Analysis summary retrieved');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis summary:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async batchAnalyzeURLs(urls: string[]): Promise<any[]> {
|
||||
try {
|
||||
console.log(`Starting batch analysis for ${urls.length} URLs`);
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/batch-analyze', { urls });
|
||||
console.log(`Batch analysis completed for ${urls.length} URLs`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in batch analysis:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/health');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
129
frontend/src/api/seoDashboard.ts
Normal file
129
frontend/src/api/seoDashboard.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface SEOHealthScore {
|
||||
score: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SEOMetric {
|
||||
value: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
status: string;
|
||||
connected: boolean;
|
||||
last_sync?: string;
|
||||
data_points?: number;
|
||||
// Additional Bing-specific properties
|
||||
has_expired_tokens?: boolean;
|
||||
last_token_date?: string;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
insight: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
action_required: boolean;
|
||||
tool_path?: string;
|
||||
}
|
||||
|
||||
export interface SEODashboardData {
|
||||
health_score: SEOHealthScore;
|
||||
key_insight: string;
|
||||
priority_alert: string;
|
||||
metrics: Record<string, SEOMetric>;
|
||||
platforms: Record<string, PlatformStatus>;
|
||||
ai_insights: AIInsight[];
|
||||
last_updated: string;
|
||||
website_url?: string; // User's website URL from onboarding
|
||||
// Real data from backend
|
||||
summary?: {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
};
|
||||
timeseries?: any[];
|
||||
competitor_insights?: {
|
||||
competitor_keywords: any[];
|
||||
content_gaps: any[];
|
||||
opportunity_score: number;
|
||||
};
|
||||
}
|
||||
|
||||
// SEO Dashboard API functions
|
||||
export const seoDashboardAPI = {
|
||||
// Get complete dashboard data
|
||||
async getDashboardData(): Promise<SEODashboardData> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/data');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO dashboard data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get health score only
|
||||
async getHealthScore(): Promise<SEOHealthScore> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health-score');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO health score:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get metrics only
|
||||
async getMetrics(): Promise<Record<string, SEOMetric>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/metrics');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get platform status
|
||||
async getPlatformStatus(): Promise<Record<string, PlatformStatus>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/platforms');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get AI insights
|
||||
async getAIInsights(): Promise<AIInsight[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/insights');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI insights:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<any> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking SEO dashboard health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
209
frontend/src/api/styleDetection.ts
Normal file
209
frontend/src/api/styleDetection.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/** Style Detection API Integration */
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface StyleAnalysisRequest {
|
||||
content: {
|
||||
main_content: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysis_type?: 'comprehensive' | 'patterns';
|
||||
}
|
||||
|
||||
export interface StyleAnalysisResponse {
|
||||
success: boolean;
|
||||
analysis?: any;
|
||||
patterns?: any;
|
||||
guidelines?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlResponse {
|
||||
success: boolean;
|
||||
content?: any;
|
||||
metrics?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StyleDetectionRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
include_patterns?: boolean;
|
||||
include_guidelines?: boolean;
|
||||
}
|
||||
|
||||
export interface StyleDetectionResponse {
|
||||
success: boolean;
|
||||
crawl_result?: any;
|
||||
style_analysis?: any;
|
||||
style_patterns?: any;
|
||||
style_guidelines?: any;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Consistent API URL pattern - no hardcoded localhost fallback
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
/**
|
||||
* Analyze content style using AI
|
||||
*/
|
||||
export const analyzeContentStyle = async (request: StyleAnalysisRequest): Promise<StyleAnalysisResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/analyze', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error analyzing content style:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Crawl website content for style analysis
|
||||
*/
|
||||
export const crawlWebsiteContent = async (request: WebCrawlRequest): Promise<WebCrawlResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/crawl', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error crawling website content:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete style detection workflow
|
||||
*/
|
||||
export const completeStyleDetection = async (request: StyleDetectionRequest): Promise<StyleDetectionResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/complete', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in complete style detection:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get style detection configuration options
|
||||
*/
|
||||
export const getStyleDetectionConfiguration = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/configuration-options');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting style detection configuration:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate style detection request
|
||||
*/
|
||||
export const validateStyleDetectionRequest = (request: StyleDetectionRequest): { valid: boolean; errors: string[] } => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!request.url && !request.text_sample) {
|
||||
errors.push('Either URL or text sample is required');
|
||||
}
|
||||
|
||||
if (request.url && !request.url.startsWith('http')) {
|
||||
errors.push('URL must start with http:// or https://');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length < 50) {
|
||||
errors.push('Text sample must be at least 50 characters');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length > 10000) {
|
||||
errors.push('Text sample is too long (max 10,000 characters)');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if analysis exists for a website URL
|
||||
*/
|
||||
export const checkExistingAnalysis = async (websiteUrl: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/check-existing/${encodeURIComponent(websiteUrl)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking existing analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get analysis by ID
|
||||
*/
|
||||
export const getAnalysisById = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis by ID:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all analyses for the current session
|
||||
*/
|
||||
export const getSessionAnalyses = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/session-analyses');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session analyses:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an analysis
|
||||
*/
|
||||
export const deleteAnalysis = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
68
frontend/src/api/userData.ts
Normal file
68
frontend/src/api/userData.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface UserData {
|
||||
website_url?: string;
|
||||
session?: {
|
||||
id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
started_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
website_analysis?: {
|
||||
website_url: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
content_goals: string[];
|
||||
brand_voice: string;
|
||||
content_style: string;
|
||||
};
|
||||
api_keys?: Array<{
|
||||
id: number;
|
||||
provider: string;
|
||||
description?: string;
|
||||
}>;
|
||||
research_preferences?: {
|
||||
target_keywords: string[];
|
||||
competitor_urls: string[];
|
||||
content_topics: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const userDataAPI = {
|
||||
async getUserData(): Promise<UserData | null> {
|
||||
try {
|
||||
console.log('Fetching user data from backend...');
|
||||
const response = await apiClient.get('/api/user-data');
|
||||
console.log('User data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getWebsiteURL(): Promise<string | null> {
|
||||
try {
|
||||
console.log('Fetching website URL...');
|
||||
const response = await apiClient.get('/api/user-data/website-url');
|
||||
console.log('Website URL received:', response.data);
|
||||
return response.data.website_url || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website URL:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getOnboardingData(): Promise<any> {
|
||||
try {
|
||||
console.log('Fetching onboarding data...');
|
||||
const response = await apiClient.get('/api/user-data/onboarding');
|
||||
console.log('Onboarding data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching onboarding data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
32
frontend/src/api/videoStudioApi.ts
Normal file
32
frontend/src/api/videoStudioApi.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Video Studio API Client
|
||||
*/
|
||||
|
||||
import { aiApiClient } from './client';
|
||||
|
||||
const API_BASE = '/api/video-studio';
|
||||
|
||||
export interface PromptOptimizeRequest {
|
||||
text: string;
|
||||
mode?: 'image' | 'video';
|
||||
style?: 'default' | 'artistic' | 'photographic' | 'technical' | 'anime' | 'realistic';
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface PromptOptimizeResponse {
|
||||
optimized_prompt: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a prompt using WaveSpeed prompt optimizer
|
||||
*/
|
||||
export async function optimizePrompt(
|
||||
request: PromptOptimizeRequest
|
||||
): Promise<PromptOptimizeResponse> {
|
||||
const response = await aiApiClient.post<PromptOptimizeResponse>(
|
||||
`${API_BASE}/optimize-prompt`,
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Website Analysis Monitoring API Client
|
||||
* Provides typed functions for fetching website analysis monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface WebsiteAnalysisTask {
|
||||
id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
competitor_id: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
frequency_days: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
user_website_tasks: WebsiteAnalysisTask[];
|
||||
competitor_tasks: WebsiteAnalysisTask[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
failed_tasks: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisLogsResponse {
|
||||
logs: WebsiteAnalysisExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface RetryWebsiteAnalysisResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
task: {
|
||||
id: number;
|
||||
website_url: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get website analysis status for a user
|
||||
*/
|
||||
export const getWebsiteAnalysisStatus = async (
|
||||
userId: string
|
||||
): Promise<WebsiteAnalysisStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for website analysis tasks
|
||||
*/
|
||||
export const getWebsiteAnalysisLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
offset: number = 0,
|
||||
taskId?: number
|
||||
): Promise<WebsiteAnalysisLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis logs');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually retry a failed website analysis task
|
||||
*/
|
||||
export const retryWebsiteAnalysis = async (
|
||||
taskId: number
|
||||
): Promise<RetryWebsiteAnalysisResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/scheduler/website-analysis/retry/${taskId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error retrying website analysis:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to retry website analysis');
|
||||
}
|
||||
};
|
||||
|
||||
83
frontend/src/api/wix.ts
Normal file
83
frontend/src/api/wix.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Wix API Client
|
||||
* Handles Wix connection status and operations
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
sites: Array<{
|
||||
id: string;
|
||||
blog_url: string;
|
||||
blog_id: string;
|
||||
created_at: string;
|
||||
scope: string;
|
||||
}>;
|
||||
total_sites: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class WixAPI {
|
||||
private baseUrl = '/api/wix';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set the auth token getter function
|
||||
*/
|
||||
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||
this.getAuthToken = getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated API client with auth token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Wix connection status
|
||||
*/
|
||||
async getStatus(): Promise<WixStatus> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Wix API: Error getting status:', error);
|
||||
return {
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: error.response?.data?.detail || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Wix service
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
await client.get(`${this.baseUrl}/connection/status`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Wix API: Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wixAPI = new WixAPI();
|
||||
286
frontend/src/api/wordpress.ts
Normal file
286
frontend/src/api/wordpress.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* WordPress API client for ALwrity frontend.
|
||||
* Handles WordPress site connections, content publishing, and management.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WordPressSite {
|
||||
id: number;
|
||||
site_url: string;
|
||||
site_name: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WordPressSiteRequest {
|
||||
site_url: string;
|
||||
site_name: string;
|
||||
username: string;
|
||||
app_password: string;
|
||||
}
|
||||
|
||||
export interface WordPressPublishRequest {
|
||||
site_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
featured_image_path?: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'publish' | 'private';
|
||||
meta_description?: string;
|
||||
}
|
||||
|
||||
export interface WordPressPublishResponse {
|
||||
success: boolean;
|
||||
post_id?: number;
|
||||
post_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WordPressPost {
|
||||
id: number;
|
||||
wp_post_id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
site_name: string;
|
||||
site_url: string;
|
||||
}
|
||||
|
||||
export interface WordPressStatusResponse {
|
||||
connected: boolean;
|
||||
sites?: WordPressSite[];
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface WordPressHealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
class WordPressAPI {
|
||||
private baseUrl = '/api/wordpress';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set authentication token getter
|
||||
*/
|
||||
setAuthTokenGetter(getter: () => Promise<string | null>) {
|
||||
this.getAuthToken = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated client with token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
if (this.getAuthToken) {
|
||||
const token = await this.getAuthToken();
|
||||
if (token) {
|
||||
// Create a new client instance with the auth header
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress connection status
|
||||
*/
|
||||
async getStatus(): Promise<WordPressStatusResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Handle 404 gracefully - endpoint may not exist yet
|
||||
if (error?.response?.status === 404) {
|
||||
// Return empty status instead of throwing
|
||||
return {
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0
|
||||
};
|
||||
}
|
||||
// Only log non-404 errors
|
||||
console.error('WordPress API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new WordPress site connection
|
||||
*/
|
||||
async addSite(siteData: WordPressSiteRequest): Promise<WordPressSite> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/sites`, siteData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error adding site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all WordPress sites for the current user
|
||||
*/
|
||||
async getSites(): Promise<WordPressSite[]> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sites`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error getting sites:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a WordPress site
|
||||
*/
|
||||
async disconnectSite(siteId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/sites/${siteId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error disconnecting site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish content to WordPress
|
||||
*/
|
||||
async publishContent(publishData: WordPressPublishRequest): Promise<WordPressPublishResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/publish`, publishData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error publishing content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get published posts from WordPress sites
|
||||
*/
|
||||
async getPosts(siteId?: number): Promise<WordPressPost[]> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const params = siteId ? { site_id: siteId } : {};
|
||||
const response = await client.get(`${this.baseUrl}/posts`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error getting posts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post status (draft/publish/private)
|
||||
*/
|
||||
async updatePostStatus(postId: number, status: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.put(`${this.baseUrl}/posts/${postId}/status`, null, {
|
||||
params: { status }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error updating post status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a WordPress post
|
||||
*/
|
||||
async deletePost(postId: number, force: boolean = false): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/posts/${postId}`, {
|
||||
params: { force }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error deleting post:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test WordPress site connection
|
||||
*/
|
||||
async testConnection(siteData: WordPressSiteRequest): Promise<boolean> {
|
||||
try {
|
||||
// This would typically be a separate endpoint for testing connections
|
||||
// For now, we'll try to add the site and see if it succeeds
|
||||
await this.addSite(siteData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<WordPressHealthResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WordPress site URL
|
||||
*/
|
||||
validateSiteUrl(url: string): boolean {
|
||||
try {
|
||||
// Remove protocol if present
|
||||
const cleanUrl = url.replace(/^https?:\/\//, '');
|
||||
|
||||
// Basic URL validation
|
||||
const urlPattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.([a-zA-Z]{2,}|[a-zA-Z]{2,}\.[a-zA-Z]{2,})$/;
|
||||
|
||||
return urlPattern.test(cleanUrl) || cleanUrl.includes('localhost') || cleanUrl.includes('127.0.0.1');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format WordPress site URL
|
||||
*/
|
||||
formatSiteUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wordpressAPI = new WordPressAPI();
|
||||
export default wordpressAPI;
|
||||
113
frontend/src/api/wordpressOAuth.ts
Normal file
113
frontend/src/api/wordpressOAuth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* WordPress OAuth2 API client for ALwrity frontend.
|
||||
* Handles WordPress.com OAuth2 authentication flow.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WordPressOAuthResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface WordPressOAuthStatus {
|
||||
connected: boolean;
|
||||
sites: WordPressOAuthSite[];
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface WordPressOAuthSite {
|
||||
id: number;
|
||||
blog_id: string;
|
||||
blog_url: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class WordPressOAuthAPI {
|
||||
private baseUrl = '/wp';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set authentication token getter
|
||||
*/
|
||||
setAuthTokenGetter(getter: () => Promise<string | null>) {
|
||||
this.getAuthToken = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated client with token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress OAuth2 authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<WordPressOAuthResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/auth/url`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error getting auth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress OAuth connection status
|
||||
*/
|
||||
async getStatus(): Promise<WordPressOAuthStatus> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a WordPress site
|
||||
*/
|
||||
async disconnectSite(tokenId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/disconnect/${tokenId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error disconnecting site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string; version: string }> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wordpressOAuthAPI = new WordPressOAuthAPI();
|
||||
export default wordpressOAuthAPI;
|
||||
45
frontend/src/assets/README.md
Normal file
45
frontend/src/assets/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Assets Directory
|
||||
|
||||
This directory contains all static assets used throughout the ALwrity application.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/assets/
|
||||
├── images/ # Image assets
|
||||
│ ├── alwrity_logo.png # ALwrity company logo
|
||||
│ └── AskAlwrity-min.ico # ALwrity Co-Pilot icon
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### ALwrity Logo (`alwrity_logo.png`)
|
||||
- **Location**: `src/assets/images/alwrity_logo.png`
|
||||
- **Usage**: Company branding in headers, navigation, and branding elements
|
||||
- **Format**: PNG with transparency
|
||||
- **Size**: 188KB, optimized for web
|
||||
|
||||
### ALwrity Co-Pilot Icon (`AskAlwrity-min.ico`)
|
||||
- **Location**: `src/assets/images/AskAlwrity-min.ico`
|
||||
- **Usage**: CopilotKit trigger button icon
|
||||
- **Format**: ICO format for optimal icon display
|
||||
- **Size**: 79KB
|
||||
|
||||
## Import Examples
|
||||
|
||||
```typescript
|
||||
// In components
|
||||
import alwrityLogo from '../../assets/images/alwrity_logo.png';
|
||||
import alwrityIcon from '../../assets/images/AskAlwrity-min.ico';
|
||||
|
||||
// In CSS
|
||||
background-image: url('../../../assets/images/AskAlwrity-min.ico');
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All assets are optimized for web use
|
||||
- ICO format is used for the Co-Pilot icon to ensure crisp display at various sizes
|
||||
- PNG format is used for the logo to maintain transparency
|
||||
- Assets are organized by type for easy maintenance
|
||||
BIN
frontend/src/assets/images/ALwrity-assistive-writing.png
Normal file
BIN
frontend/src/assets/images/ALwrity-assistive-writing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
frontend/src/assets/images/Alwrity-fact-check.png
Normal file
BIN
frontend/src/assets/images/Alwrity-fact-check.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
frontend/src/assets/images/AskAlwrity-min.ico
Normal file
BIN
frontend/src/assets/images/AskAlwrity-min.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/src/assets/images/Fact check1.png
Normal file
BIN
frontend/src/assets/images/Fact check1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 KiB |
BIN
frontend/src/assets/images/alwrity_logo.png
Normal file
BIN
frontend/src/assets/images/alwrity_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,526 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Search as SearchIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface AnalyticsSummary {
|
||||
period_days: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
total_queries: number;
|
||||
avg_ctr: number;
|
||||
ctr_trend: number;
|
||||
top_queries: Array<{
|
||||
query: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
count: number;
|
||||
}>;
|
||||
daily_metrics_count: number;
|
||||
data_quality: string;
|
||||
}
|
||||
|
||||
interface DailyMetric {
|
||||
date: string;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
total_queries: number;
|
||||
avg_ctr: number;
|
||||
avg_position: number;
|
||||
clicks_change: number;
|
||||
impressions_change: number;
|
||||
ctr_change: number;
|
||||
top_queries: any[];
|
||||
collected_at: string;
|
||||
}
|
||||
|
||||
interface TopQuery {
|
||||
query: string;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
avg_ctr: number;
|
||||
avg_position: number;
|
||||
days_appeared: number;
|
||||
category: string;
|
||||
is_brand: boolean;
|
||||
}
|
||||
|
||||
const BingAnalyticsStorage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [siteUrl, setSiteUrl] = useState('https://www.alwrity.com/');
|
||||
const [days, setDays] = useState(30);
|
||||
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
|
||||
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
|
||||
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
|
||||
const [sortBy, setSortBy] = useState('clicks');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const loadAnalyticsSummary = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/summary', {
|
||||
params: { site_url: siteUrl, days: days }
|
||||
});
|
||||
|
||||
setSummary(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load analytics summary');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days]);
|
||||
|
||||
const collectData = useCallback(async () => {
|
||||
try {
|
||||
setCollecting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
await apiClient.post('/bing-analytics/collect-data', null, {
|
||||
params: { site_url: siteUrl, days_back: days }
|
||||
});
|
||||
|
||||
setSuccess(`Data collection started for ${siteUrl}. This may take a few minutes.`);
|
||||
|
||||
// Refresh summary after a delay
|
||||
setTimeout(() => {
|
||||
loadAnalyticsSummary();
|
||||
}, 5000);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to start data collection');
|
||||
} finally {
|
||||
setCollecting(false);
|
||||
}
|
||||
}, [siteUrl, days, loadAnalyticsSummary]);
|
||||
|
||||
const loadDailyMetrics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/daily-metrics', {
|
||||
params: { site_url: siteUrl, days: days }
|
||||
});
|
||||
|
||||
setDailyMetrics(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load daily metrics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days]);
|
||||
|
||||
const loadTopQueries = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/top-queries', {
|
||||
params: {
|
||||
site_url: siteUrl,
|
||||
days: days,
|
||||
limit: 20,
|
||||
sort_by: sortBy
|
||||
}
|
||||
});
|
||||
|
||||
setTopQueries(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load top queries');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days, sortBy]);
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const getChangeColor = (change: number) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getChangeIcon = (change: number) => {
|
||||
if (change > 0) return '↗';
|
||||
if (change < 0) return '↘';
|
||||
return '→';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (siteUrl) {
|
||||
loadAnalyticsSummary();
|
||||
}
|
||||
}, [siteUrl, days, loadAnalyticsSummary]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<StorageIcon color="primary" />
|
||||
Bing Analytics Storage
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
This tool collects and stores Bing Webmaster Tools analytics data for historical analysis and trend tracking.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Data Collection & Analysis
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site URL"
|
||||
value={siteUrl}
|
||||
onChange={(e) => setSiteUrl(e.target.value)}
|
||||
placeholder="https://www.example.com/"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Days"
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 30)}
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={collectData}
|
||||
disabled={collecting || !siteUrl}
|
||||
startIcon={collecting ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
fullWidth
|
||||
>
|
||||
{collecting ? 'Collecting...' : 'Collect Data'}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadAnalyticsSummary}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <AssessmentIcon />}
|
||||
fullWidth
|
||||
>
|
||||
Refresh Summary
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analytics Summary */}
|
||||
{summary && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
Analytics Summary ({summary.period_days} days)
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(summary.total_clicks)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Clicks
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="secondary">
|
||||
{formatNumber(summary.total_impressions)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Impressions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info">
|
||||
{summary.avg_ctr.toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Avg CTR
|
||||
<Chip
|
||||
label={`${getChangeIcon(summary.ctr_trend)} ${summary.ctr_trend.toFixed(1)}%`}
|
||||
color={getChangeColor(summary.ctr_trend)}
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success">
|
||||
{summary.total_queries}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unique Queries
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Top Performing Queries
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{summary.top_queries.slice(0, 5).map((query, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{index + 1}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={query.query}
|
||||
secondary={`${query.clicks} clicks • ${query.impressions} impressions • ${((query.clicks / query.impressions) * 100).toFixed(1)}% CTR`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Chip
|
||||
label={`Data Quality: ${summary.data_quality}`}
|
||||
color={summary.data_quality === 'good' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Queries Table */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon color="primary" />
|
||||
Top Queries
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sort By"
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem value="clicks">Clicks</MenuItem>
|
||||
<MenuItem value="impressions">Impressions</MenuItem>
|
||||
<MenuItem value="ctr">CTR</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadTopQueries}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <SearchIcon />}
|
||||
>
|
||||
Load Top Queries
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{topQueries.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Query</TableCell>
|
||||
<TableCell align="right">Clicks</TableCell>
|
||||
<TableCell align="right">Impressions</TableCell>
|
||||
<TableCell align="right">CTR</TableCell>
|
||||
<TableCell align="right">Avg Position</TableCell>
|
||||
<TableCell align="right">Days</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Brand</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topQueries.map((query, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{query.query}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">{query.total_clicks}</TableCell>
|
||||
<TableCell align="right">{query.total_impressions}</TableCell>
|
||||
<TableCell align="right">{query.avg_ctr.toFixed(1)}%</TableCell>
|
||||
<TableCell align="right">{query.avg_position > 0 ? query.avg_position.toFixed(1) : 'N/A'}</TableCell>
|
||||
<TableCell align="right">{query.days_appeared}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={query.category} size="small" color="default" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={query.is_brand ? 'Brand' : 'Generic'}
|
||||
size="small"
|
||||
color={query.is_brand ? 'primary' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Metrics */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarIcon color="primary" />
|
||||
Daily Metrics
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadDailyMetrics}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <CalendarIcon />}
|
||||
>
|
||||
Load Daily Data
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{dailyMetrics.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell align="right">Clicks</TableCell>
|
||||
<TableCell align="right">Impressions</TableCell>
|
||||
<TableCell align="right">Queries</TableCell>
|
||||
<TableCell align="right">CTR</TableCell>
|
||||
<TableCell align="right">Position</TableCell>
|
||||
<TableCell align="right">Clicks Δ</TableCell>
|
||||
<TableCell align="right">CTR Δ</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dailyMetrics.slice(0, 10).map((metric, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{new Date(metric.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">{metric.total_clicks}</TableCell>
|
||||
<TableCell align="right">{metric.total_impressions}</TableCell>
|
||||
<TableCell align="right">{metric.total_queries}</TableCell>
|
||||
<TableCell align="right">{metric.avg_ctr.toFixed(1)}%</TableCell>
|
||||
<TableCell align="right">{metric.avg_position > 0 ? metric.avg_position.toFixed(1) : 'N/A'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={`${getChangeIcon(metric.clicks_change)} ${metric.clicks_change.toFixed(1)}%`}
|
||||
color={getChangeColor(metric.clicks_change)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={`${getChangeIcon(metric.ctr_change)} ${metric.ctr_change.toFixed(1)}%`}
|
||||
color={getChangeColor(metric.ctr_change)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BingAnalyticsStorage;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
|
||||
const BingCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const error = params.get('error');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend to complete token exchange
|
||||
await fetch(`/bing/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (e) {
|
||||
// Continue; backend HTML callback may already be handled in popup
|
||||
}
|
||||
|
||||
// Notify opener and close if this is a popup window
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_SUCCESS', success: true }, '*');
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: redirect back to onboarding
|
||||
window.location.replace('/onboarding?step=5');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
|
||||
if (window.opener) window.close();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
padding={3}
|
||||
>
|
||||
{error ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Connection Failed</Typography>
|
||||
<Typography>{error}</Typography>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Typography variant="h6">Connecting to Bing Webmaster Tools...</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Please wait while we complete the authentication process.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BingCallbackPage;
|
||||
561
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
561
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { debug } from '../../utils/debug';
|
||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
import HallucinationChecker from './HallucinationChecker';
|
||||
import Publisher from './Publisher';
|
||||
import OutlineGenerator from './OutlineGenerator';
|
||||
import OutlineRefiner from './OutlineRefiner';
|
||||
import { SEOProcessor } from './SEO';
|
||||
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
||||
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
|
||||
import HeaderBar from './BlogWriterUtils/HeaderBar';
|
||||
import PhaseContent from './BlogWriterUtils/PhaseContent';
|
||||
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
|
||||
import { useCopilotKitHealth } from '../../hooks/useCopilotKitHealth';
|
||||
import { useSEOManager } from './BlogWriterUtils/useSEOManager';
|
||||
import { usePhaseActionHandlers } from './BlogWriterUtils/usePhaseActionHandlers';
|
||||
import { useBlogWriterPolling } from './BlogWriterUtils/useBlogWriterPolling';
|
||||
import { useCopilotSuggestions } from './BlogWriterUtils/useCopilotSuggestions';
|
||||
import { usePhaseRestoration } from './BlogWriterUtils/usePhaseRestoration';
|
||||
import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
|
||||
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
||||
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
|
||||
export 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');
|
||||
document.documentElement.classList.add('blog-writer-page');
|
||||
return () => {
|
||||
document.body.classList.remove('blog-writer-page');
|
||||
document.documentElement.classList.remove('blog-writer-page');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check CopilotKit health status
|
||||
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
|
||||
enabled: true, // Enable health checking
|
||||
});
|
||||
|
||||
// Use custom hook for all state management
|
||||
const {
|
||||
research,
|
||||
outline,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
sections,
|
||||
seoAnalysis,
|
||||
genMode,
|
||||
seoMetadata,
|
||||
continuityRefresh,
|
||||
outlineTaskId,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
setSeoMetadata,
|
||||
setContinuityRefresh,
|
||||
setOutlineTaskId,
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setSectionImages,
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
handleTitleSelect,
|
||||
handleCustomTitle,
|
||||
handleOutlineConfirmed,
|
||||
handleOutlineRefined,
|
||||
handleContentUpdate,
|
||||
handleContentSave
|
||||
} = useBlogWriterState();
|
||||
|
||||
// SEO Manager - handles all SEO-related logic
|
||||
// Initialize phase navigation with temporary false value for seoRecommendationsApplied
|
||||
const [tempSeoRecommendationsApplied] = React.useState(false);
|
||||
const {
|
||||
phases: tempPhases,
|
||||
currentPhase: tempCurrentPhase,
|
||||
navigateToPhase: tempNavigateToPhase,
|
||||
setCurrentPhase: tempSetCurrentPhase,
|
||||
resetUserSelection
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
tempSeoRecommendationsApplied
|
||||
);
|
||||
|
||||
const {
|
||||
isSEOAnalysisModalOpen,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
isSEOMetadataModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
seoRecommendationsApplied,
|
||||
setSeoRecommendationsApplied,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
handleApplySeoRecommendations,
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
} = useSEOManager({
|
||||
sections,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
currentPhase: tempCurrentPhase,
|
||||
navigateToPhase: tempNavigateToPhase,
|
||||
setContentConfirmed,
|
||||
setSeoAnalysis,
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle: setSelectedTitle as (title: string | null) => void,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
});
|
||||
|
||||
// Phase navigation hook with correct seoRecommendationsApplied
|
||||
const {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
);
|
||||
|
||||
// Update ref when navigateToPhase changes
|
||||
React.useEffect(() => {
|
||||
navigateToPhaseRef.current = navigateToPhase;
|
||||
}, [navigateToPhase]);
|
||||
|
||||
// Phase restoration logic
|
||||
usePhaseRestoration({
|
||||
copilotKitAvailable,
|
||||
research,
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
});
|
||||
|
||||
// All SEO management logic is now in useSEOManager hook above
|
||||
|
||||
// Custom hooks for complex functionality
|
||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||
outline,
|
||||
sections,
|
||||
setSections
|
||||
);
|
||||
|
||||
const { convertMarkdownToHTML } = useMarkdownProcessor(
|
||||
outline,
|
||||
sections
|
||||
);
|
||||
|
||||
// Store navigateToPhase in a ref for use in polling callbacks
|
||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||
|
||||
// Polling hooks - extracted to useBlogWriterPolling
|
||||
const {
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
} = useBlogWriterPolling({
|
||||
onResearchComplete: handleResearchComplete,
|
||||
onOutlineComplete: handleOutlineComplete,
|
||||
onOutlineError: handleOutlineError,
|
||||
onSectionsUpdate: setSections,
|
||||
onContentConfirmed: () => {
|
||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||
setContentConfirmed(true);
|
||||
},
|
||||
navigateToPhase: (phase) => {
|
||||
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||
// Use ref to access navigateToPhase (defined later in component)
|
||||
if (navigateToPhaseRef.current) {
|
||||
setTimeout(() => {
|
||||
navigateToPhaseRef.current?.(phase);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Modal visibility management - extracted to useModalVisibility
|
||||
const {
|
||||
showModal,
|
||||
showOutlineModal,
|
||||
setShowOutlineModal,
|
||||
isMediumGenerationStarting,
|
||||
setIsMediumGenerationStarting,
|
||||
} = useModalVisibility({
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
outlinePolling,
|
||||
});
|
||||
|
||||
// CopilotKit suggestions management - extracted to useCopilotSuggestions
|
||||
// Check if sections exist AND have actual content (not just empty strings)
|
||||
const hasContent = React.useMemo(() => {
|
||||
const sectionKeys = Object.keys(sections);
|
||||
if (sectionKeys.length === 0) return false;
|
||||
// Check if at least one section has actual content
|
||||
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
|
||||
return sectionsWithContent.length > 0;
|
||||
}, [sections]);
|
||||
const {
|
||||
suggestions,
|
||||
setSuggestionsRef,
|
||||
} = useCopilotSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Refs and tracking logic - extracted to useBlogWriterRefs
|
||||
useBlogWriterRefs({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
});
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
navigateToPhase(phaseId);
|
||||
if (phaseId === 'seo') {
|
||||
if (seoAnalysis) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
|
||||
} else {
|
||||
runSEOAnalysisDirect();
|
||||
}
|
||||
}
|
||||
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
const outlineGenRef = useRef<any>(null);
|
||||
|
||||
// Callback to handle cached outline completion
|
||||
const handleCachedOutlineComplete = useCallback((result: { outline: any[], title_options?: string[] }) => {
|
||||
if (result.outline && Array.isArray(result.outline)) {
|
||||
handleOutlineComplete(result);
|
||||
}
|
||||
}, [handleOutlineComplete]);
|
||||
|
||||
// Callback to handle cached content completion
|
||||
const handleCachedContentComplete = useCallback((cachedSections: Record<string, string>) => {
|
||||
if (cachedSections && Object.keys(cachedSections).length > 0) {
|
||||
setSections(cachedSections);
|
||||
debug.log('[BlogWriter] Cached content loaded into state', { sections: Object.keys(cachedSections).length });
|
||||
}
|
||||
}, [setSections]);
|
||||
|
||||
// Phase action handlers for when CopilotKit is unavailable - extracted to usePhaseActionHandlers
|
||||
const {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
handlePublishAction,
|
||||
} = usePhaseActionHandlers({
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
mediumPolling,
|
||||
outlineGenRef,
|
||||
setOutline,
|
||||
setContentConfirmed,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
onOutlineComplete: handleCachedOutlineComplete,
|
||||
onContentComplete: handleCachedContentComplete,
|
||||
});
|
||||
|
||||
// Handle medium generation start from OutlineFeedbackForm
|
||||
const handleMediumGenerationStarted = (taskId: string) => {
|
||||
console.log('Starting medium generation polling for task:', taskId);
|
||||
setIsMediumGenerationStarting(false); // Clear the starting state
|
||||
mediumPolling.startPolling(taskId);
|
||||
};
|
||||
|
||||
// Show modal immediately when copilot action is triggered
|
||||
const handleMediumGenerationTriggered = () => {
|
||||
console.log('Medium generation triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
};
|
||||
|
||||
useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
navigateToPhase,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1a1a1a',
|
||||
overflow: 'auto'
|
||||
}} className="blog-writer-container">
|
||||
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
|
||||
{copilotKitAvailable && (
|
||||
<CopilotKitComponents
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
sections={sections}
|
||||
selectedTitle={selectedTitle}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onOutlineCreated={setOutline}
|
||||
onOutlineUpdated={setOutline}
|
||||
onTitleOptionsSet={setTitleOptions}
|
||||
onOutlineConfirmed={handleOutlineConfirmed}
|
||||
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
|
||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||
onRewriteStarted={(taskId) => {
|
||||
console.log('Starting rewrite polling for task:', taskId);
|
||||
rewritePolling.startPolling(taskId);
|
||||
}}
|
||||
onRewriteTriggered={() => {
|
||||
console.log('Rewrite triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
}}
|
||||
setFlowAnalysisCompleted={setFlowAnalysisCompleted}
|
||||
setFlowAnalysisResults={setFlowAnalysisResults}
|
||||
setContinuityRefresh={setContinuityRefresh}
|
||||
researchPolling={researchPolling}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
ref={outlineGenRef}
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
onModalShow={() => setShowOutlineModal(true)}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onOutlineCreated={(outline, titleOptions) => {
|
||||
// Handle cached outline from CopilotKit action (same as header button)
|
||||
setOutline(outline);
|
||||
if (titleOptions) {
|
||||
setTitleOptions(titleOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
onOutlineUpdated={setOutline}
|
||||
/>
|
||||
<SEOProcessor
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
seoMetadata={seoMetadata}
|
||||
onSEOAnalysis={setSeoAnalysis}
|
||||
onSEOMetadata={setSeoMetadata}
|
||||
/>
|
||||
<HallucinationChecker
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
||||
applyClaimFix={applyClaimFix}
|
||||
/>
|
||||
<Publisher
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
seoMetadata={seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Phase navigation header - always visible as default interface */}
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Landing section - extracted to BlogWriterLandingSection */}
|
||||
<BlogWriterLandingSection
|
||||
research={research}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
currentPhase={currentPhase}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
/>
|
||||
|
||||
{research && (
|
||||
<>
|
||||
<PhaseContent
|
||||
currentPhase={currentPhase}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
setOutline={setOutline}
|
||||
sections={sections}
|
||||
handleContentUpdate={handleContentUpdate}
|
||||
handleContentSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
outlineGenRef={outlineGenRef}
|
||||
blogWriterApi={blogWriterApi}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
contentConfirmed={contentConfirmed}
|
||||
seoAnalysis={seoAnalysis}
|
||||
seoMetadata={seoMetadata}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onOutlineGenerationStart={(taskId) => {
|
||||
setOutlineTaskId(taskId);
|
||||
outlinePolling.startPolling(taskId);
|
||||
setShowOutlineModal(true);
|
||||
}}
|
||||
onContentGenerationStart={handleMediumGenerationStarted}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<WriterCopilotSidebar
|
||||
suggestions={suggestions}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
/>
|
||||
|
||||
<TaskProgressModals
|
||||
showOutlineModal={showOutlineModal}
|
||||
outlinePolling={outlinePolling}
|
||||
showModal={showModal}
|
||||
rewritePolling={rewritePolling}
|
||||
mediumPolling={mediumPolling}
|
||||
/>
|
||||
|
||||
{/* SEO Analysis Modal */}
|
||||
<SEOAnalysisModal
|
||||
isOpen={isSEOAnalysisModalOpen}
|
||||
onClose={handleSEOModalClose}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
onApplyRecommendations={handleApplySeoRecommendations}
|
||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
<SEOMetadataModal
|
||||
isOpen={isSEOMetadataModalOpen}
|
||||
onClose={() => setIsSEOMetadataModalOpen(false)}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
outline={outline}
|
||||
seoAnalysis={seoAnalysis}
|
||||
onMetadataGenerated={(metadata) => {
|
||||
console.log('SEO metadata generated:', metadata);
|
||||
setSeoMetadata(metadata);
|
||||
// Metadata is now saved and will be used when publishing to WordPress/Wix
|
||||
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
|
||||
// Publisher component will use this metadata when calling publish API
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriter;
|
||||
68
frontend/src/components/BlogWriter/BlogWriterLanding.md
Normal file
68
frontend/src/components/BlogWriter/BlogWriterLanding.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# BlogWriterLanding Component
|
||||
|
||||
A beautiful, animated landing page for the ALwrity Blog Writer that utilizes the custom background image with artistic button placement and subtle animations.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎨 **Visual Design**
|
||||
- **Full-screen background image** (`/blog-writer-bg.png`) with horizontal stretching (56% width) and left alignment
|
||||
- **Gradient overlays** for subtle depth
|
||||
- **Clean, minimal design** without decorative elements
|
||||
- **Glassmorphism effects** on secondary buttons
|
||||
|
||||
### ✨ **Interactions**
|
||||
- **Button hover effects** with smooth transitions
|
||||
- **Modal interactions** with clean transitions
|
||||
- **Responsive hover states** for all interactive elements
|
||||
|
||||
### 🚀 **Interactive Elements**
|
||||
- **Primary CTA Button**: "Chat/Write with ALwrity Copilot" with gradient background
|
||||
- **Secondary CTA Button**: "ALwrity Blog Writer SuperPowers" opens feature modal
|
||||
- **SuperPowers Modal**: Showcases 6 key features with hover effects
|
||||
- **Responsive design** that works on all screen sizes
|
||||
|
||||
### 🎯 **User Experience**
|
||||
- **Clear messaging** about the blog writing capabilities
|
||||
- **Feature showcase** in an engaging modal format
|
||||
- **Clean, focused messaging** without distracting text
|
||||
- **Clean transitions** between states
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Handle start writing action
|
||||
// This can trigger copilot interaction
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `onStartWriting: () => void` - Callback function called when user clicks "Chat/Write with ALwrity Copilot"
|
||||
|
||||
## Integration
|
||||
|
||||
The component integrates with:
|
||||
- **useCopilotTrigger hook** for copilot interaction
|
||||
- **BlogWriter main component** as the initial state
|
||||
- **Responsive design** that adapts to different screen sizes
|
||||
|
||||
## Styling
|
||||
|
||||
All styles are inline with CSS-in-JS approach for:
|
||||
- **Better performance** (no external CSS files)
|
||||
- **Component isolation** (styles don't leak)
|
||||
- **Dynamic theming** (easy to modify colors/effects)
|
||||
- **Animation control** (precise timing and effects)
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Semantic HTML** structure
|
||||
- **Keyboard navigation** support
|
||||
- **Screen reader** friendly
|
||||
- **High contrast** text and buttons
|
||||
- **Focus indicators** for interactive elements
|
||||
383
frontend/src/components/BlogWriter/BlogWriterLanding.tsx
Normal file
383
frontend/src/components/BlogWriter/BlogWriterLanding.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
|
||||
|
||||
interface BlogWriterLandingProps {
|
||||
onStartWriting: () => void;
|
||||
}
|
||||
|
||||
const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting }) => {
|
||||
const [showSuperPowers, setShowSuperPowers] = useState(false);
|
||||
const { triggerResearch } = useCopilotTrigger();
|
||||
|
||||
const handleStartWriting = () => {
|
||||
// Open the copilot sidebar (same functionality as LinkedIn writer)
|
||||
const copilotButton = document.querySelector('.copilotkit-open-button') ||
|
||||
document.querySelector('[data-copilot-open]') ||
|
||||
document.querySelector('button[aria-label*="Open"]') ||
|
||||
document.querySelector('.alwrity-copilot-sidebar button');
|
||||
|
||||
if (copilotButton) {
|
||||
(copilotButton as HTMLElement).click();
|
||||
} else {
|
||||
// Fallback: scroll to bottom right where the button should be
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Also call the parent callback
|
||||
onStartWriting();
|
||||
};
|
||||
|
||||
const superPowers = [
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "AI-Powered Research",
|
||||
description: "Comprehensive research with Google Search grounding, competitor analysis, and content gap identification"
|
||||
},
|
||||
{
|
||||
icon: "📝",
|
||||
title: "Intelligent Outline Generation",
|
||||
description: "AI-generated outlines with source mapping, grounding insights, and optimization recommendations"
|
||||
},
|
||||
{
|
||||
icon: "✨",
|
||||
title: "Content Enhancement",
|
||||
description: "Section-by-section content generation with SEO optimization and engagement improvements"
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "SEO Intelligence",
|
||||
description: "Advanced SEO analysis, metadata generation, and keyword optimization for maximum visibility"
|
||||
},
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "Fact-Checking & Quality",
|
||||
description: "Hallucination detection, claim verification, and content quality assurance"
|
||||
},
|
||||
{
|
||||
icon: "🚀",
|
||||
title: "Multi-Platform Publishing",
|
||||
description: "Direct publishing to WordPress, Wix, and other platforms with scheduling capabilities"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundSize: '56% auto',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
|
||||
{/* Main content container */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
{/* Main heading */}
|
||||
<div style={{
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '3.5rem',
|
||||
fontWeight: '700',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: '0 0 20px 0',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
AI-First, Contextual, Click through Blog Writer
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{/* Primary CTA Button */}
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '18px 48px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 25px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-3px) scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 35px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</span>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
transition: 'left 0.5s ease'
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{/* Secondary CTA Button */}
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(true)}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#1976d2',
|
||||
border: '2px solid #1976d2',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(10px)',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1976d2';
|
||||
e.currentTarget.style.color = 'white';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.color = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Modal with 6 Phases */}
|
||||
{showSuperPowers && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '1400px',
|
||||
minHeight: '100%',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '30px',
|
||||
paddingBottom: '20px',
|
||||
borderBottom: '2px solid #f0f0f0'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '2rem',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#666', fontSize: '1.1rem' }}>
|
||||
Discover the powerful features that make ALwrity the ultimate blog writing assistant
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '2rem',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
padding: '8px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.color = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#999';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 6 Phases Section */}
|
||||
<BlogWriterPhasesSection />
|
||||
|
||||
{/* Quick SuperPowers Grid */}
|
||||
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '1.5rem',
|
||||
textAlign: 'center',
|
||||
color: '#333'
|
||||
}}>
|
||||
Quick Feature Overview
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.1rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
marginTop: '30px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterLanding;
|
||||
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
|
||||
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
|
||||
|
||||
interface PhaseFeature {
|
||||
title: string;
|
||||
description: string;
|
||||
details: string[];
|
||||
imagePlaceholder: string;
|
||||
}
|
||||
|
||||
interface BlogPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
shortDescription: string;
|
||||
features: PhaseFeature[];
|
||||
technicalDetails: {
|
||||
aiModel: string;
|
||||
promptType: string;
|
||||
outputFormat: string;
|
||||
integration: string;
|
||||
};
|
||||
videoPlaceholder: string;
|
||||
}
|
||||
|
||||
const BlogWriterPhasesSection: React.FC = () => {
|
||||
const [activePhase, setActivePhase] = useState<number | null>(null);
|
||||
|
||||
const phases: BlogPhase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research & Strategy',
|
||||
icon: '🔍',
|
||||
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
|
||||
features: [
|
||||
{
|
||||
title: 'Google Search Grounding',
|
||||
description: 'Real-time web research using Gemini\'s native Google Search integration',
|
||||
details: [
|
||||
'Single API call for comprehensive research',
|
||||
'Live web data from credible sources',
|
||||
'Automatic source extraction and citation',
|
||||
'Current trends and 2024-2025 insights',
|
||||
'Market analysis and forecasts'
|
||||
],
|
||||
imagePlaceholder: '/images/research-google-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Competitor Analysis',
|
||||
description: 'Identify top players and content opportunities in your niche',
|
||||
details: [
|
||||
'Top competitor content analysis',
|
||||
'Content gap identification',
|
||||
'Unique angle discovery',
|
||||
'Market positioning insights',
|
||||
'Competitive advantage opportunities'
|
||||
],
|
||||
imagePlaceholder: '/images/research-competitor.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Intelligence',
|
||||
description: 'Comprehensive keyword analysis with SEO opportunities',
|
||||
details: [
|
||||
'Primary, secondary, and long-tail keyword identification',
|
||||
'Search volume and competition analysis',
|
||||
'Keyword clustering and grouping',
|
||||
'Content optimization suggestions',
|
||||
'Target audience keyword mapping'
|
||||
],
|
||||
imagePlaceholder: '/images/research-keywords.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Angle Generation',
|
||||
description: 'AI-generated compelling content angles for maximum engagement',
|
||||
details: [
|
||||
'5 unique content angle suggestions',
|
||||
'Trending topic identification',
|
||||
'Audience pain point mapping',
|
||||
'Viral potential assessment',
|
||||
'Expert opinion synthesis'
|
||||
],
|
||||
imagePlaceholder: '/images/research-angles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro with Google Search Grounding',
|
||||
promptType: 'Comprehensive research prompt',
|
||||
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
|
||||
integration: 'GeminiGroundedProvider via research_service.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase1-research.mp4'
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Intelligent Outline',
|
||||
icon: '📝',
|
||||
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
|
||||
features: [
|
||||
{
|
||||
title: 'AI Outline Generation',
|
||||
description: 'Comprehensive outline based on research with SEO optimization',
|
||||
details: [
|
||||
'Section-by-section breakdown',
|
||||
'Subheadings and key points',
|
||||
'Target word counts per section',
|
||||
'Logical flow and progression',
|
||||
'SEO-optimized structure'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Mapping & Grounding',
|
||||
description: 'Connect each section to research sources with citations',
|
||||
details: [
|
||||
'Automatic source-to-section mapping',
|
||||
'Grounding support scores',
|
||||
'Citation suggestions',
|
||||
'Source credibility ratings',
|
||||
'Reference verification'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Interactive Refinement',
|
||||
description: 'Human-in-the-loop editing with AI assistance',
|
||||
details: [
|
||||
'Add, remove, merge sections',
|
||||
'Reorder and restructure',
|
||||
'AI enhancement suggestions',
|
||||
'Custom instructions support',
|
||||
'Multiple outline versions'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-refine.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Title Generation',
|
||||
description: 'Multiple SEO-optimized title options',
|
||||
details: [
|
||||
'AI-generated title variations',
|
||||
'SEO score per title',
|
||||
'Engagement potential analysis',
|
||||
'Keyword integration',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-titles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
|
||||
promptType: 'Structured outline prompt with research context',
|
||||
outputFormat: 'JSON outline with sections, headings, key_points, references',
|
||||
integration: 'OutlineService via parallel_processor.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase2-outline.mp4'
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content Generation',
|
||||
icon: '✨',
|
||||
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
|
||||
features: [
|
||||
{
|
||||
title: 'Smart Content Generation',
|
||||
description: 'AI-powered section writing with context awareness',
|
||||
details: [
|
||||
'Section-by-section generation',
|
||||
'Context memory across sections',
|
||||
'Smooth transitions between sections',
|
||||
'Consistent tone and style',
|
||||
'Natural keyword integration'
|
||||
],
|
||||
imagePlaceholder: '/images/content-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Continuity Analysis',
|
||||
description: 'Real-time flow and coherence monitoring',
|
||||
details: [
|
||||
'Narrative flow assessment',
|
||||
'Coherence scoring',
|
||||
'Transition quality analysis',
|
||||
'Tone consistency tracking',
|
||||
'Content quality metrics'
|
||||
],
|
||||
imagePlaceholder: '/images/content-continuity.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Integration',
|
||||
description: 'Automatic citation and source reference',
|
||||
details: [
|
||||
'Relevant URL selection',
|
||||
'Natural citation insertion',
|
||||
'Source attribution',
|
||||
'Evidence-backed content',
|
||||
'Reference management'
|
||||
],
|
||||
imagePlaceholder: '/images/content-sources.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Medium Blog Mode',
|
||||
description: 'Quick generation for Medium-style articles',
|
||||
details: [
|
||||
'Single-call full blog generation',
|
||||
'Medium-optimized formatting',
|
||||
'Engagement-focused structure',
|
||||
'SEO-ready output',
|
||||
'Fast turnaround option'
|
||||
],
|
||||
imagePlaceholder: '/images/content-medium.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
|
||||
promptType: 'Context-aware section prompt with research',
|
||||
outputFormat: 'Markdown content with transitions and metrics',
|
||||
integration: 'EnhancedContentGenerator with ContextMemory'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase3-content.mp4'
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO Analysis',
|
||||
icon: '📈',
|
||||
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive SEO Scoring',
|
||||
description: 'Multi-dimensional SEO analysis across key factors',
|
||||
details: [
|
||||
'Overall SEO score (0-100)',
|
||||
'Structure optimization score',
|
||||
'Keyword optimization rating',
|
||||
'Readability assessment',
|
||||
'Quality metrics evaluation'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-scoring.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Actionable Recommendations',
|
||||
description: 'AI-powered improvement suggestions',
|
||||
details: [
|
||||
'Priority-ranked fixes',
|
||||
'Specific text improvements',
|
||||
'Keyword density optimization',
|
||||
'Heading structure suggestions',
|
||||
'Content enhancement ideas'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-recommendations.jpg'
|
||||
},
|
||||
{
|
||||
title: 'AI-Powered Content Refinement',
|
||||
description: 'Automatically apply SEO recommendations',
|
||||
details: [
|
||||
'Smart content rewriting',
|
||||
'Preserves original intent',
|
||||
'Natural keyword integration',
|
||||
'Readability improvement',
|
||||
'Structure optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-apply.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Analysis',
|
||||
description: 'Deep dive into keyword performance',
|
||||
details: [
|
||||
'Primary keyword density',
|
||||
'Semantic keyword usage',
|
||||
'Long-tail keyword opportunities',
|
||||
'Keyword distribution heatmap',
|
||||
'Optimization recommendations'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-keywords.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Parallel non-AI analyzers + single AI call',
|
||||
promptType: 'Structured SEO analysis prompt',
|
||||
outputFormat: 'Comprehensive SEO report with scores and recommendations',
|
||||
integration: 'BlogContentSEOAnalyzer with parallel processing'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase4-seo.mp4'
|
||||
},
|
||||
{
|
||||
id: 'metadata',
|
||||
name: 'SEO Metadata',
|
||||
icon: '🎯',
|
||||
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive Metadata',
|
||||
description: 'All-in-one SEO metadata generation',
|
||||
details: [
|
||||
'SEO-optimized title (50-60 chars)',
|
||||
'Meta description with CTA',
|
||||
'URL slug optimization',
|
||||
'Blog tags and categories',
|
||||
'Social hashtags'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-comprehensive.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Open Graph & Twitter Cards',
|
||||
description: 'Rich social media previews',
|
||||
details: [
|
||||
'OG title and description',
|
||||
'Twitter card optimization',
|
||||
'Image preview settings',
|
||||
'Social engagement boost',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-social.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Structured Data',
|
||||
description: 'Schema.org markup for rich snippets',
|
||||
details: [
|
||||
'Article schema',
|
||||
'Organization markup',
|
||||
'Breadcrumb schema',
|
||||
'FAQ schema support',
|
||||
'Enhanced search results'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-schema.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Multi-Format Output',
|
||||
description: 'Ready-to-use metadata in all formats',
|
||||
details: [
|
||||
'HTML meta tags',
|
||||
'JSON-LD structured data',
|
||||
'WordPress export format',
|
||||
'Wix integration format',
|
||||
'One-click copy options'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-export.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
|
||||
promptType: 'Personalized metadata prompt with context',
|
||||
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
|
||||
integration: 'BlogSEOMetadataGenerator with optimization'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase5-metadata.mp4'
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish & Distribute',
|
||||
icon: '🚀',
|
||||
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
|
||||
features: [
|
||||
{
|
||||
title: 'Multi-Platform Publishing',
|
||||
description: 'Publish to multiple platforms simultaneously',
|
||||
details: [
|
||||
'WordPress direct publishing',
|
||||
'Wix blog integration',
|
||||
'Medium publishing',
|
||||
'Custom blog platforms',
|
||||
'API integrations'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-platforms.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Scheduling',
|
||||
description: 'Schedule posts for optimal timing',
|
||||
details: [
|
||||
'Time-based scheduling',
|
||||
'Timezone management',
|
||||
'Bulk scheduling support',
|
||||
'Calendar integration',
|
||||
'Reminder notifications'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-schedule.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Revision Management',
|
||||
description: 'Track and manage content versions',
|
||||
details: [
|
||||
'Version history',
|
||||
'Change tracking',
|
||||
'Rollback capabilities',
|
||||
'A/B testing support',
|
||||
'Performance comparison'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-versions.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Analytics Integration',
|
||||
description: 'Post-publish performance tracking',
|
||||
details: [
|
||||
'View count tracking',
|
||||
'Engagement metrics',
|
||||
'SEO performance',
|
||||
'Traffic analysis',
|
||||
'Conversion tracking'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-analytics.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Platform-specific API integrations',
|
||||
promptType: 'N/A - publishing only',
|
||||
outputFormat: 'Published content with URL',
|
||||
integration: 'Platform APIs via Publisher component'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase6-publish.mp4'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
|
||||
<Container maxWidth="lg">
|
||||
{/* Section Title */}
|
||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
Complete AI Blog Writing Workflow
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
|
||||
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Cards */}
|
||||
<Grid container spacing={4}>
|
||||
{phases.map((phase, index) => (
|
||||
<Grid item xs={12} md={6} key={phase.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: activePhase === index ? 2 : 1,
|
||||
borderColor: activePhase === index ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: 6,
|
||||
}
|
||||
}}
|
||||
onClick={() => setActivePhase(activePhase === index ? null : index)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
|
||||
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
|
||||
{phase.icon}
|
||||
</Typography>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h5" fontWeight={600} gutterBottom>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{phase.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`Phase ${index + 1}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{activePhase === index && (
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* Video Placeholder */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
🎥 Video: {phase.videoPlaceholder}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features Grid */}
|
||||
<Grid container spacing={2} mb={3}>
|
||||
{phase.features.map((feature, idx) => (
|
||||
<Grid item xs={12} sm={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '4/3',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
📷 Image
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{feature.details.slice(0, 3).map((detail, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
|
||||
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{detail}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Technical Details */}
|
||||
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesome sx={{ fontSize: 18 }} />
|
||||
Technical Implementation
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Integration</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{phase.technicalDetails.integration}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterPhasesSection;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import BlogWriterLanding from '../BlogWriterLanding';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
|
||||
interface BlogWriterLandingSectionProps {
|
||||
research: any;
|
||||
copilotKitAvailable: boolean;
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
onResearchComplete: (research: any) => void;
|
||||
}
|
||||
|
||||
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
|
||||
research,
|
||||
copilotKitAvailable,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
}) => {
|
||||
// Only show landing/initial content when no research exists
|
||||
// Phase navigation header is always visible, so this is just the initial content
|
||||
if (!research) {
|
||||
return (
|
||||
<>
|
||||
{/* Show manual research form when on research phase and CopilotKit unavailable */}
|
||||
{!copilotKitAvailable && currentPhase === 'research' && (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
{/* Show landing page for CopilotKit flow or when not on research phase */}
|
||||
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Navigate to research phase to start the workflow
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import KeywordInputForm from '../KeywordInputForm';
|
||||
import ResearchAction from '../ResearchAction';
|
||||
import { CustomOutlineForm } from '../CustomOutlineForm';
|
||||
import { ResearchDataActions } from '../ResearchDataActions';
|
||||
import { EnhancedOutlineActions } from '../EnhancedOutlineActions';
|
||||
import OutlineFeedbackForm from '../OutlineFeedbackForm';
|
||||
import { RewriteFeedbackForm } from '../RewriteFeedbackForm';
|
||||
|
||||
interface CopilotKitComponentsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
selectedTitle: string | null;
|
||||
onResearchComplete: (research: any) => void;
|
||||
onOutlineCreated: (outline: any[]) => void;
|
||||
onOutlineUpdated: (outline: any[]) => void;
|
||||
onTitleOptionsSet: (titles: any[]) => void;
|
||||
onOutlineConfirmed: () => void;
|
||||
onOutlineRefined: (feedback?: string) => void;
|
||||
onMediumGenerationStarted: (taskId: string) => void;
|
||||
onMediumGenerationTriggered: () => void;
|
||||
onRewriteStarted: (taskId: string) => void;
|
||||
onRewriteTriggered: () => void;
|
||||
setFlowAnalysisCompleted: (completed: boolean) => void;
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
|
||||
researchPolling: any;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
sections,
|
||||
selectedTitle,
|
||||
onResearchComplete,
|
||||
onOutlineCreated,
|
||||
onOutlineUpdated,
|
||||
onTitleOptionsSet,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
onMediumGenerationTriggered,
|
||||
onRewriteStarted,
|
||||
onRewriteTriggered,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setContinuityRefresh,
|
||||
researchPolling,
|
||||
navigateToPhase,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<KeywordInputForm
|
||||
onResearchComplete={onResearchComplete}
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
|
||||
|
||||
<ResearchDataActions
|
||||
research={research}
|
||||
onOutlineCreated={onOutlineCreated}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
<EnhancedOutlineActions
|
||||
outline={outline}
|
||||
onOutlineUpdated={onOutlineUpdated}
|
||||
/>
|
||||
<OutlineFeedbackForm
|
||||
outline={outline}
|
||||
research={research!}
|
||||
onOutlineConfirmed={onOutlineConfirmed}
|
||||
onOutlineRefined={onOutlineRefined}
|
||||
onMediumGenerationStarted={onMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={onMediumGenerationTriggered}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle ?? undefined}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onFlowAnalysisComplete={(analysis) => {
|
||||
console.log('Flow analysis completed:', analysis);
|
||||
setFlowAnalysisCompleted(true);
|
||||
setFlowAnalysisResults(analysis);
|
||||
// Trigger a refresh of continuity badges
|
||||
setContinuityRefresh((prev: number) => (prev || 0) + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Rewrite Feedback Form - Only show when content exists */}
|
||||
{Object.keys(sections).length > 0 && (
|
||||
<RewriteFeedbackForm
|
||||
research={research!}
|
||||
outline={outline}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle || 'Untitled'}
|
||||
onRewriteStarted={onRewriteStarted}
|
||||
onRewriteTriggered={onRewriteTriggered}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import PhaseNavigation, { PhaseActionHandlers } from '../PhaseNavigation';
|
||||
|
||||
interface HeaderBarProps {
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
copilotKitAvailable?: boolean;
|
||||
actionHandlers?: PhaseActionHandlers;
|
||||
hasResearch?: boolean;
|
||||
hasOutline?: boolean;
|
||||
outlineConfirmed?: boolean;
|
||||
hasContent?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
hasSEOAnalysis?: boolean;
|
||||
seoRecommendationsApplied?: boolean;
|
||||
hasSEOMetadata?: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OutlineCtaBannerProps {
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
Next: Create Outline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineCtaBanner;
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import ResearchResults from '../ResearchResults';
|
||||
import EnhancedTitleSelector from '../EnhancedTitleSelector';
|
||||
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
|
||||
import { BlogEditor } from '../WYSIWYG';
|
||||
import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
import ManualOutlineButton from '../ManualOutlineButton';
|
||||
import ManualContentButton from '../ManualContentButton';
|
||||
|
||||
interface PhaseContentProps {
|
||||
currentPhase: string;
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
titleOptions: any[];
|
||||
selectedTitle?: string | null;
|
||||
researchTitles: any[];
|
||||
aiGeneratedTitles: any[];
|
||||
sourceMappingStats: any;
|
||||
groundingInsights: any;
|
||||
optimizationResults: any;
|
||||
researchCoverage: any;
|
||||
setOutline: (o: any) => void;
|
||||
sections: Record<string, string>;
|
||||
handleContentUpdate: any;
|
||||
handleContentSave: any;
|
||||
continuityRefresh: number | null;
|
||||
flowAnalysisResults: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
blogWriterApi: any;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
onTitleSelect: any;
|
||||
onCustomTitle: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
copilotKitAvailable?: boolean; // Whether CopilotKit is available
|
||||
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
|
||||
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
|
||||
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
currentPhase,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
setOutline,
|
||||
sections,
|
||||
handleContentUpdate,
|
||||
handleContentSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
outlineGenRef,
|
||||
blogWriterApi,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
onTitleSelect,
|
||||
onCustomTitle,
|
||||
sectionImages,
|
||||
setSectionImages,
|
||||
copilotKitAvailable = true,
|
||||
onResearchComplete,
|
||||
onOutlineGenerationStart,
|
||||
onContentGenerationStart,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{currentPhase === 'research' && (
|
||||
<>
|
||||
{research ? (
|
||||
<ResearchResults research={research} />
|
||||
) : (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Start Your Research</h3>
|
||||
<p>Use the copilot to begin researching your blog topic.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'outline' && research && (
|
||||
<>
|
||||
{outline.length === 0 && (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
|
||||
) : (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{outline.length > 0 ? (
|
||||
<>
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle || undefined}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
research={research}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
/>
|
||||
</>
|
||||
) : !copilotKitAvailable ? (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Create Your Outline</h3>
|
||||
<p>Use the copilot to generate an outline based on your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'content' && outline.length > 0 && (
|
||||
<>
|
||||
{outlineConfirmed ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Confirm Your Outline</h3>
|
||||
<p>Review and confirm your outline before generating content.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ManualContentButton
|
||||
outline={outline}
|
||||
research={research}
|
||||
blogTitle={selectedTitle || undefined}
|
||||
sections={sections}
|
||||
onGenerationStart={onContentGenerationStart}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
|
||||
<>
|
||||
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Loading Content...</h3>
|
||||
<p>Please wait while your content is being optimized.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback for SEO phase if conditions not met */}
|
||||
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Optimize your blog for search engines.</h3>
|
||||
<p>Complete the content phase first to enable SEO optimization.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Publish Your Blog</h3>
|
||||
<p>Your blog is ready to publish!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseContent;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { OutlineProgressModal } from '../OutlineProgressModal';
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
currentStatus: string;
|
||||
progressMessages: { message: string }[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface TaskProgressModalsProps {
|
||||
showOutlineModal: boolean;
|
||||
outlinePolling: PollingState;
|
||||
showModal: boolean;
|
||||
rewritePolling: PollingState;
|
||||
mediumPolling: PollingState;
|
||||
}
|
||||
|
||||
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
|
||||
showOutlineModal,
|
||||
outlinePolling,
|
||||
showModal,
|
||||
rewritePolling,
|
||||
mediumPolling,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error ?? null}
|
||||
/>
|
||||
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ? (
|
||||
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
|
||||
) : (
|
||||
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
|
||||
)}
|
||||
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgressModals;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { usePlatformConnections } from '../../../components/OnboardingWizard/common/usePlatformConnections';
|
||||
|
||||
interface WixConnectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConnectionSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnectionSuccess
|
||||
}) => {
|
||||
const { handleConnect, isLoading } = usePlatformConnections();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Handle OAuth success via postMessage (same pattern as onboarding)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
console.log('Wix OAuth success in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
// Close modal and notify parent
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_ERROR') {
|
||||
console.error('Wix OAuth error in modal:', event.data.error);
|
||||
setIsConnecting(false);
|
||||
setError(event.data.error || 'Wix connection failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
// Also check for URL param (fallback for same-tab redirect)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
console.log('Wix connected via URL param in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
}
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
const handleConnectClick = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
// Store current page URL so we can redirect back after OAuth completes
|
||||
// This MUST be stored before calling handleConnect to ensure it's available after redirect
|
||||
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
|
||||
// Build the redirect URL to ensure it includes the phase (publish) and works with both localhost and ngrok
|
||||
const currentPath = window.location.pathname;
|
||||
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
|
||||
// This ensures consistency between where OAuth starts and where callback happens
|
||||
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const isUsingNgrok = window.location.origin.includes('localhost') ||
|
||||
window.location.origin.includes('127.0.0.1') ||
|
||||
window.location.origin === NGROK_ORIGIN;
|
||||
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
|
||||
|
||||
// Build redirect URL with normalized origin
|
||||
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
|
||||
|
||||
try {
|
||||
// Always override any existing redirect URL when connecting from Blog Writer
|
||||
sessionStorage.setItem('wix_oauth_redirect', redirectUrl);
|
||||
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
|
||||
redirectUrl,
|
||||
currentOrigin: window.location.origin,
|
||||
redirectOrigin,
|
||||
isUsingNgrok
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
|
||||
}
|
||||
await handleConnect('wix');
|
||||
// OAuth will redirect, so we don't need to do anything else here
|
||||
// The postMessage handler or URL param handler will close the modal
|
||||
} catch (err: any) {
|
||||
console.error('Error connecting to Wix:', err);
|
||||
setIsConnecting(false);
|
||||
setError(err?.message || 'Failed to start Wix connection. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Connect Your Wix Account
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Connect your Wix account to publish blog posts directly to your website.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isConnecting && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Opening Wix authorization page...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8fafc', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>What happens next:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
<ol style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
<li>You'll be redirected to Wix to authorize ALwrity</li>
|
||||
<li>Grant permissions for blog creation and publishing</li>
|
||||
<li>You'll be redirected back to ALwrity</li>
|
||||
<li>Your blog post will be published automatically</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={isConnecting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleConnectClick}
|
||||
disabled={isConnecting || isLoading}
|
||||
startIcon={isConnecting ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect to Wix'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixConnectModal;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import React from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
|
||||
interface WriterCopilotSidebarProps {
|
||||
suggestions: any[];
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
}
|
||||
|
||||
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
suggestions,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise CopilotKit Suggestion Styling */
|
||||
|
||||
/* All suggestion chips - base styling */
|
||||
.copilotkit-suggestions button,
|
||||
.copilot-suggestions button,
|
||||
[class*="suggestion"] button,
|
||||
[class*="Suggestion"] button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
color: #4b5563;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Shine effect on hover */
|
||||
.copilotkit-suggestions button::before,
|
||||
.copilot-suggestions button::before,
|
||||
[class*="suggestion"] button::before,
|
||||
[class*="Suggestion"] button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
.copilotkit-suggestions button:hover::before,
|
||||
.copilot-suggestions button:hover::before,
|
||||
[class*="suggestion"] button:hover::before,
|
||||
[class*="Suggestion"] button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Regular suggestions - hover effects */
|
||||
.copilotkit-suggestions button:hover,
|
||||
.copilot-suggestions button:hover,
|
||||
[class*="suggestion"] button:hover:not([class*="next-suggestion"]),
|
||||
[class*="Suggestion"] button:hover:not([class*="next-suggestion"]) {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(249, 250, 251, 1) 100%);
|
||||
}
|
||||
|
||||
/* "Next:" Suggestions - Premium Enterprise Style */
|
||||
.copilotkit-suggestions button[data-is-next="true"],
|
||||
.copilot-suggestions button[data-is-next="true"],
|
||||
.copilotkit-suggestions button.next-suggestion,
|
||||
.copilot-suggestions button.next-suggestion,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"],
|
||||
.copilot-suggestions button[aria-label*="Next:"] {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3) !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
animation: nextSuggestionPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation for Next suggestions */
|
||||
@keyframes nextSuggestionPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Next suggestion hover - enhanced */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:hover,
|
||||
.copilot-suggestions button[data-is-next="true"]:hover,
|
||||
.copilotkit-suggestions button.next-suggestion:hover,
|
||||
.copilot-suggestions button.next-suggestion:hover,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:hover,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:hover {
|
||||
transform: translateY(-3px) scale(1.05) !important;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
0 3px 6px rgba(0, 0, 0, 0.15) inset,
|
||||
0 0 40px rgba(102, 126, 234, 0.6) !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 40%, #f093fb 60%, #4facfe 100%) !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Next suggestion active */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:active,
|
||||
.copilot-suggestions button[data-is-next="true"]:active,
|
||||
.copilotkit-suggestions button.next-suggestion:active,
|
||||
.copilot-suggestions button.next-suggestion:active,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:active,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:active {
|
||||
transform: translateY(-1px) scale(1.02) !important;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.1) inset !important;
|
||||
}
|
||||
|
||||
/* Next suggestion focus */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilot-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilotkit-suggestions button.next-suggestion:focus-visible,
|
||||
.copilot-suggestions button.next-suggestion:focus-visible,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:focus-visible,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4),
|
||||
0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Match buttons by text content using data attributes or class */
|
||||
/* We'll inject a data attribute via JS to identify Next suggestions */
|
||||
|
||||
/* Regular suggestion active state */
|
||||
.copilotkit-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* Focus states for regular suggestions */
|
||||
.copilotkit-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Enhanced suggestion container */
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(249, 250, 251, 0.6) 100%);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@media (min-width: 420px) {
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Inject data attributes to identify Next suggestions */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const observer = new MutationObserver(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
} else {
|
||||
btn.removeAttribute('data-is-next');
|
||||
btn.classList.remove('next-suggestion');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial run
|
||||
setTimeout(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
})();
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null && research !== undefined;
|
||||
const hasOutline = outline && outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch && research
|
||||
? {
|
||||
sources: research?.sources?.length || 0,
|
||||
queries: research?.search_queries?.length || 0,
|
||||
angles: research?.suggested_angles?.length || 0,
|
||||
primaryKeywords: research?.keyword_analysis?.primary || [],
|
||||
searchIntent: research?.keyword_analysis?.search_intent || 'informational',
|
||||
}
|
||||
: null;
|
||||
|
||||
const outlineContext = hasOutline && outline
|
||||
? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${(outline || []).map((s: any) => s?.heading || 'Untitled').join(', ')}
|
||||
- Total target words: ${(outline || []).reduce((sum: number, s: any) => sum + (s?.target_words || 0), 0)}
|
||||
- Section breakdown: ${(outline || [])
|
||||
.map(
|
||||
(s: any) => `${s?.heading || 'Untitled'} (${s?.target_words || 0} words, ${s?.subheadings?.length || 0} subheadings, ${s?.key_points?.length || 0} key points)`
|
||||
)
|
||||
.join('; ')}
|
||||
`
|
||||
: '';
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline && outline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriterCopilotSidebar;
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
type ConfirmCb = () => string | Promise<string>;
|
||||
type AnalyzeCb = () => string | Promise<string>;
|
||||
type OpenMetadataCb = () => void;
|
||||
|
||||
interface UseBlogWriterCopilotActionsParams {
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
lastSEOModalOpenRef: React.MutableRefObject<number>;
|
||||
runSEOAnalysisDirect: AnalyzeCb;
|
||||
confirmBlogContent: ConfirmCb;
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
export function useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// confirmBlogContent
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmBlogContent',
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when content is confirmed
|
||||
navigateToPhase?.('seo');
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// analyzeSEO
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeSEO',
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when SEO analysis starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
hasResearch: !!research && !!(research as any)?.keyword_analysis,
|
||||
});
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
|
||||
return 'SEO analysis is already open.';
|
||||
}
|
||||
const msg = await runSEOAnalysisDirect();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// generateSEOMetadata
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
|
||||
parameters: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Optional blog title to use for metadata generation',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
// Navigate to SEO phase when SEO metadata generation starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
|
||||
}
|
||||
openSEOMetadata();
|
||||
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useBlogWriterCopilotActions;
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useResearchPolling,
|
||||
useOutlinePolling,
|
||||
useMediumGenerationPolling,
|
||||
useRewritePolling,
|
||||
} from '../../../hooks/usePolling';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
interface UseBlogWriterPollingProps {
|
||||
onResearchComplete: (research: any) => void;
|
||||
onOutlineComplete: (outline: any) => void;
|
||||
onOutlineError: (error: any) => void;
|
||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||
}
|
||||
|
||||
export const useBlogWriterPolling = ({
|
||||
onResearchComplete,
|
||||
onOutlineComplete,
|
||||
onOutlineError,
|
||||
onSectionsUpdate,
|
||||
onContentConfirmed,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterPollingProps) => {
|
||||
// Research polling hook (for context awareness)
|
||||
const researchPolling = useResearchPolling({
|
||||
onComplete: onResearchComplete,
|
||||
onError: (error) => console.error('Research polling error:', error)
|
||||
});
|
||||
|
||||
// Outline polling hook
|
||||
const outlinePolling = useOutlinePolling({
|
||||
onComplete: onOutlineComplete,
|
||||
onError: onOutlineError
|
||||
});
|
||||
|
||||
// Medium generation polling (used after confirm if short blog)
|
||||
const mediumPolling = useMediumGenerationPolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
|
||||
// Cache the generated content (shared utility)
|
||||
if (Object.keys(newSections).length > 0) {
|
||||
const sectionIds = Object.keys(newSections);
|
||||
blogWriterCache.cacheContent(newSections, sectionIds);
|
||||
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply medium generation result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Medium generation failed:', err)
|
||||
});
|
||||
|
||||
// Rewrite polling hook (used for blog rewrite operations)
|
||||
const rewritePolling = useRewritePolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply rewrite result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Rewrite failed:', err)
|
||||
});
|
||||
|
||||
// Memoize polling state objects to prevent unnecessary recalculations
|
||||
const researchPollingState = React.useMemo(
|
||||
() => ({ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus }),
|
||||
[researchPolling.isPolling, researchPolling.currentStatus]
|
||||
);
|
||||
const outlinePollingState = React.useMemo(
|
||||
() => ({ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus }),
|
||||
[outlinePolling.isPolling, outlinePolling.currentStatus]
|
||||
);
|
||||
const mediumPollingState = React.useMemo(
|
||||
() => ({ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }),
|
||||
[mediumPolling.isPolling, mediumPolling.currentStatus]
|
||||
);
|
||||
|
||||
return {
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UseBlogWriterRefsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
contentConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
currentPhase: string;
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
resetUserSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBlogWriterRefs = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
}: UseBlogWriterRefsProps) => {
|
||||
// Track when outlines/content become available for the first time
|
||||
const prevOutlineLenRef = useRef<number>(outline.length);
|
||||
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
|
||||
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
|
||||
|
||||
useEffect(() => {
|
||||
const prevLen = prevOutlineLenRef.current;
|
||||
if (research && prevLen === 0 && outline.length > 0) {
|
||||
resetUserSelection();
|
||||
}
|
||||
prevOutlineLenRef.current = outline.length;
|
||||
}, [research, outline.length, resetUserSelection]);
|
||||
|
||||
// Only reset user selection when transitioning from not-confirmed to confirmed
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevOutlineConfirmedRef.current;
|
||||
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
|
||||
resetUserSelection(); // Allow auto-progression to content phase
|
||||
}
|
||||
prevOutlineConfirmedRef.current = outlineConfirmed;
|
||||
}, [outlineConfirmed, sections, resetUserSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevContentConfirmedRef.current;
|
||||
if (!wasConfirmed && contentConfirmed) {
|
||||
resetUserSelection(); // Allow auto-progression to SEO phase
|
||||
}
|
||||
prevContentConfirmedRef.current = contentConfirmed;
|
||||
}, [contentConfirmed, resetUserSelection]);
|
||||
|
||||
// Log critical state changes only (reduce noise)
|
||||
const lastPhaseRef = useRef<string>('');
|
||||
const lastSeoOpenRef = useRef<boolean>(false);
|
||||
const lastSectionsLenRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPhase !== lastPhaseRef.current) {
|
||||
debug.log('[BlogWriter] Phase changed', { currentPhase });
|
||||
lastPhaseRef.current = currentPhase;
|
||||
}
|
||||
}, [currentPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
const open = isSEOAnalysisModalOpen;
|
||||
if (open !== lastSeoOpenRef.current) {
|
||||
debug.log('[BlogWriter] SEO modal', { isOpen: open });
|
||||
lastSeoOpenRef.current = open;
|
||||
}
|
||||
}, [isSEOAnalysisModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const len = Object.keys(sections || {}).length;
|
||||
if (len !== lastSectionsLenRef.current) {
|
||||
debug.log('[BlogWriter] Sections updated', { count: len });
|
||||
lastSectionsLenRef.current = len;
|
||||
}
|
||||
}, [sections]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { useSuggestions } from '../SuggestionsGenerator';
|
||||
|
||||
interface UseCopilotSuggestionsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
researchPollingState: { isPolling: boolean; currentStatus: any };
|
||||
outlinePollingState: { isPolling: boolean; currentStatus: any };
|
||||
mediumPollingState: { isPolling: boolean; currentStatus: any };
|
||||
hasContent: boolean;
|
||||
flowAnalysisCompleted: boolean;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
seoRecommendationsApplied: boolean;
|
||||
}
|
||||
|
||||
export const useCopilotSuggestions = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
}: UseCopilotSuggestionsProps) => {
|
||||
const suggestions = useSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling: researchPollingState,
|
||||
outlinePolling: outlinePollingState,
|
||||
mediumPolling: mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Drive CopilotKit suggestions programmatically
|
||||
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
|
||||
const setSuggestionsRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
|
||||
}, [copilotHeadless]);
|
||||
|
||||
const suggestionsPayload = useMemo(
|
||||
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
|
||||
[suggestions]
|
||||
);
|
||||
const prevSuggestionsRef = useRef<string>("__init__");
|
||||
const suggestionsJson = useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!setSuggestionsRef.current) return;
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch {}
|
||||
}, [suggestionsJson, suggestionsPayload]);
|
||||
|
||||
// Force-sync Copilot suggestions right after SEO recommendations applied
|
||||
useEffect(() => {
|
||||
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
|
||||
try {
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to push Copilot suggestions after SEO apply:', e);
|
||||
}
|
||||
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
setSuggestionsRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UseModalVisibilityProps {
|
||||
mediumPolling: { isPolling: boolean };
|
||||
rewritePolling: { isPolling: boolean };
|
||||
outlinePolling: { isPolling: boolean };
|
||||
}
|
||||
|
||||
export const useModalVisibility = ({
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
outlinePolling,
|
||||
}: UseModalVisibilityProps) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||
|
||||
// Add minimum display time for modal
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
setShowModal(true);
|
||||
setModalStartTime(Date.now());
|
||||
} else if (!mediumPolling.isPolling && !rewritePolling.isPolling && !isMediumGenerationStarting && showModal) {
|
||||
const elapsed = Date.now() - (modalStartTime || 0);
|
||||
const minDisplayTime = 2000; // 2 seconds minimum
|
||||
|
||||
if (elapsed < minDisplayTime) {
|
||||
setTimeout(() => {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}, minDisplayTime - elapsed);
|
||||
} else {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}
|
||||
}
|
||||
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
|
||||
// Handle outline modal visibility
|
||||
useEffect(() => {
|
||||
if (outlinePolling.isPolling && !showOutlineModal) {
|
||||
setShowOutlineModal(true);
|
||||
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
||||
// Add a small delay to ensure user sees completion message
|
||||
setTimeout(() => {
|
||||
setShowOutlineModal(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [outlinePolling.isPolling, showOutlineModal]);
|
||||
|
||||
return {
|
||||
showModal,
|
||||
setShowModal,
|
||||
showOutlineModal,
|
||||
setShowOutlineModal,
|
||||
isMediumGenerationStarting,
|
||||
setIsMediumGenerationStarting,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { mediumBlogApi } from '../../../services/blogWriterApi';
|
||||
import { researchCache } from '../../../services/researchCache';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
interface UsePhaseActionHandlersProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
selectedTitle: string | null;
|
||||
contentConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
handleOutlineConfirmed: () => void;
|
||||
setIsMediumGenerationStarting: (starting: boolean) => void;
|
||||
mediumPolling: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
setOutline: (outline: any[]) => void;
|
||||
setContentConfirmed: (confirmed: boolean) => void;
|
||||
setIsSEOAnalysisModalOpen: (open: boolean) => void;
|
||||
setIsSEOMetadataModalOpen: (open: boolean) => void;
|
||||
runSEOAnalysisDirect: () => string;
|
||||
onOutlineComplete?: (outline: any) => void;
|
||||
onContentComplete?: (sections: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export const usePhaseActionHandlers = ({
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
mediumPolling,
|
||||
outlineGenRef,
|
||||
setOutline,
|
||||
setContentConfirmed,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
onOutlineComplete,
|
||||
onContentComplete,
|
||||
}: UsePhaseActionHandlersProps) => {
|
||||
const handleResearchAction = useCallback(() => {
|
||||
navigateToPhase('research');
|
||||
debug.log('[BlogWriter] Research action triggered - navigating to research phase');
|
||||
// Note: Research caching is handled by ManualResearchForm component
|
||||
}, [navigateToPhase]);
|
||||
|
||||
const handleOutlineAction = useCallback(async () => {
|
||||
if (!research) {
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
debug.log('[BlogWriter] Using cached outline from localStorage', { sections: cachedOutline.outline.length });
|
||||
setOutline(cachedOutline.outline);
|
||||
if (onOutlineComplete) {
|
||||
onOutlineComplete({ outline: cachedOutline.outline, title_options: cachedOutline.title_options });
|
||||
}
|
||||
navigateToPhase('outline');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPhase('outline');
|
||||
if (outlineGenRef.current) {
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Failed to generate outline');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
alert(`Outline generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
debug.log('[BlogWriter] Outline action triggered');
|
||||
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
|
||||
|
||||
const handleContentAction = useCallback(async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please generate and confirm an outline first.');
|
||||
return;
|
||||
}
|
||||
if (!research) {
|
||||
alert('Research data is required for content generation.');
|
||||
return;
|
||||
}
|
||||
navigateToPhase('content');
|
||||
|
||||
// Confirm outline first
|
||||
handleOutlineConfirmed();
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
|
||||
if (cachedContent) {
|
||||
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
|
||||
if (onContentComplete) {
|
||||
onContentComplete(cachedContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check if sections already exist in current state (shared utility)
|
||||
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
|
||||
debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length });
|
||||
return;
|
||||
}
|
||||
|
||||
// If short/medium blog (<=1000 words), trigger content generation automatically
|
||||
const target = Number(
|
||||
research?.keyword_analysis?.blog_length ||
|
||||
(research as any)?.word_count_target ||
|
||||
localStorage.getItem('blog_length_target') ||
|
||||
0
|
||||
);
|
||||
|
||||
if (target && target <= 1000) {
|
||||
try {
|
||||
setIsMediumGenerationStarting(true);
|
||||
const payload = {
|
||||
title: selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
|
||||
sections: outline.map(s => ({
|
||||
id: s.id,
|
||||
heading: s.heading,
|
||||
keyPoints: s.key_points,
|
||||
subheadings: s.subheadings,
|
||||
keywords: s.keywords,
|
||||
targetWords: s.target_words,
|
||||
references: s.references,
|
||||
})),
|
||||
globalTargetWords: target,
|
||||
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [],
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
setIsMediumGenerationStarting(false);
|
||||
mediumPolling.startPolling(task_id);
|
||||
debug.log('[BlogWriter] Content action triggered - medium generation started', { task_id });
|
||||
} catch (error) {
|
||||
console.error('Content generation failed:', error);
|
||||
setIsMediumGenerationStarting(false);
|
||||
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
// For longer blogs, just confirm outline - user will use manual button
|
||||
debug.log('[BlogWriter] Content action triggered - outline confirmed (manual content generation required)');
|
||||
}
|
||||
}, [outline, research, selectedTitle, sections, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, mediumPolling, onContentComplete]);
|
||||
|
||||
const handleSEOAction = useCallback(() => {
|
||||
if (!contentConfirmed) {
|
||||
// Mark content as confirmed when SEO action is clicked
|
||||
setContentConfirmed(true);
|
||||
}
|
||||
navigateToPhase('seo');
|
||||
runSEOAnalysisDirect();
|
||||
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
|
||||
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
|
||||
|
||||
const handleApplySEORecommendations = useCallback(() => {
|
||||
navigateToPhase('seo');
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] Apply SEO Recommendations action triggered - opening SEO analysis modal');
|
||||
}, [navigateToPhase, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
const handlePublishAction = useCallback(() => {
|
||||
// Can be called from SEO phase (after recommendations applied) or publish phase
|
||||
navigateToPhase('seo'); // Stay in SEO phase if called from there
|
||||
setIsSEOMetadataModalOpen(true);
|
||||
debug.log('[BlogWriter] Generate SEO Metadata action triggered - opening SEO metadata modal');
|
||||
}, [navigateToPhase, setIsSEOMetadataModalOpen]);
|
||||
|
||||
return {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
handlePublishAction,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UsePhaseRestorationProps {
|
||||
copilotKitAvailable: boolean;
|
||||
research: any;
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
setCurrentPhase: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const usePhaseRestoration = ({
|
||||
copilotKitAvailable,
|
||||
research,
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
}: UsePhaseRestorationProps) => {
|
||||
// When CopilotKit is unavailable and there's no research, ensure we're on research phase
|
||||
useEffect(() => {
|
||||
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research') {
|
||||
navigateToPhase('research');
|
||||
debug.log('[BlogWriter] Auto-navigating to research phase (CopilotKit unavailable)');
|
||||
}
|
||||
}, [copilotKitAvailable, research, phases.length, currentPhase, navigateToPhase]);
|
||||
|
||||
// Restore phase from navigation state on mount (after subscription renewal)
|
||||
// Note: The PricingPage restores the phase to localStorage before redirecting
|
||||
// This effect ensures the phase is applied when BlogWriter loads
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Wait for phases to be initialized
|
||||
if (phases.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we just returned from pricing page (has restored phase in localStorage)
|
||||
const restoredPhase = localStorage.getItem('blogwriter_current_phase');
|
||||
const userSelectedPhase = localStorage.getItem('blogwriter_user_selected_phase') === 'true';
|
||||
|
||||
// Only restore if:
|
||||
// 1. A phase was saved (restoredPhase exists)
|
||||
// 2. User had manually selected a phase (indicates they were actively working)
|
||||
// 3. The phase is different from current (to avoid unnecessary updates)
|
||||
if (restoredPhase && userSelectedPhase && restoredPhase !== currentPhase) {
|
||||
const targetPhase = phases.find(p => p.id === restoredPhase);
|
||||
if (targetPhase && !targetPhase.disabled) {
|
||||
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);
|
||||
setCurrentPhase(restoredPhase);
|
||||
// Phase restoration complete - the usePhaseNavigation hook will handle persistence
|
||||
} else {
|
||||
console.log('[BlogWriter] Restored phase is disabled or not found, keeping current phase:', {
|
||||
restoredPhase,
|
||||
currentPhase,
|
||||
targetPhaseExists: !!targetPhase,
|
||||
targetPhaseDisabled: targetPhase?.disabled
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BlogWriter] Failed to restore phase from navigation state:', error);
|
||||
}
|
||||
}, [phases, currentPhase, setCurrentPhase]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
|
||||
if (key === undefined || key === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = String(key).trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const safeContent = content !== undefined && content !== null ? String(content) : '';
|
||||
map.set(trimmed, safeContent);
|
||||
map.set(trimmed.toLowerCase(), safeContent);
|
||||
};
|
||||
|
||||
const getIdCandidatesForSection = (section: any, index: number): string[] => {
|
||||
const rawCandidates = [
|
||||
section?.id,
|
||||
section?.section_id,
|
||||
section?.sectionId,
|
||||
section?.sectionID,
|
||||
section?.heading_id,
|
||||
`section_${index + 1}`,
|
||||
`Section ${index + 1}`,
|
||||
`section${index + 1}`,
|
||||
`s${index + 1}`,
|
||||
`S${index + 1}`,
|
||||
`${index + 1}`,
|
||||
];
|
||||
|
||||
const normalized = rawCandidates
|
||||
.map((value) => (value === undefined || value === null ? '' : String(value).trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
const buildExistingContentMap = (sectionsRecord: Record<string, string>): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
if (!sectionsRecord) {
|
||||
return map;
|
||||
}
|
||||
Object.entries(sectionsRecord).forEach(([key, value]) => {
|
||||
registerContentKey(map, key, value ?? '');
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const buildResponseContentMaps = (responseSections: any[]): { byId: Map<string, string>; byHeading: Map<string, string> } => {
|
||||
const byId = new Map<string, string>();
|
||||
const byHeading = new Map<string, string>();
|
||||
|
||||
if (!responseSections) {
|
||||
return { byId, byHeading };
|
||||
}
|
||||
|
||||
responseSections.forEach((section, index) => {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
const content = section?.content;
|
||||
const normalizedContent = content !== undefined && content !== null ? String(content).trim() : '';
|
||||
if (!normalizedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerContentKey(byId, section?.id, normalizedContent);
|
||||
registerContentKey(byId, section?.section_id, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionId, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionID, normalizedContent);
|
||||
registerContentKey(byId, `section_${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `Section ${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `section${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `s${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `S${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `${index + 1}`, normalizedContent);
|
||||
|
||||
const heading = section?.heading || section?.title;
|
||||
if (heading) {
|
||||
registerContentKey(byHeading, heading, normalizedContent);
|
||||
}
|
||||
});
|
||||
|
||||
return { byId, byHeading };
|
||||
};
|
||||
|
||||
const getPrimaryKeyForOutlineSection = (outlineSection: any, index: number): string => {
|
||||
const candidates = getIdCandidatesForSection(outlineSection, index);
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0];
|
||||
}
|
||||
const fallbackHeading = outlineSection?.heading || outlineSection?.title;
|
||||
if (fallbackHeading) {
|
||||
const trimmed = String(fallbackHeading).trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return `section_${index + 1}`;
|
||||
};
|
||||
|
||||
const resolveContentForOutlineSection = (
|
||||
outlineSection: any,
|
||||
index: number,
|
||||
responseSections: any[],
|
||||
responseById: Map<string, string>,
|
||||
responseByHeading: Map<string, string>,
|
||||
existingContentMap: Map<string, string>
|
||||
): { content: string; matchedKey: string } => {
|
||||
const idCandidates = getIdCandidatesForSection(outlineSection, index);
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (responseById.has(candidate)) {
|
||||
return { content: responseById.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (responseById.has(lower)) {
|
||||
return { content: responseById.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
const heading = outlineSection?.heading || outlineSection?.title;
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (responseByHeading.has(lowerHeading)) {
|
||||
return { content: responseByHeading.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (responseByHeading.has(headingKey)) {
|
||||
return { content: responseByHeading.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseSection = responseSections?.[index];
|
||||
if (responseSection?.content) {
|
||||
const normalizedContent = String(responseSection.content).trim();
|
||||
if (normalizedContent) {
|
||||
return {
|
||||
content: normalizedContent,
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (existingContentMap.has(candidate)) {
|
||||
return { content: existingContentMap.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (existingContentMap.has(lower)) {
|
||||
return { content: existingContentMap.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (existingContentMap.has(lowerHeading)) {
|
||||
return { content: existingContentMap.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (existingContentMap.has(headingKey)) {
|
||||
return { content: existingContentMap.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSEOManagerProps {
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
outline: any[];
|
||||
selectedTitle: string | null;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
setContentConfirmed: (confirmed: boolean) => void;
|
||||
setSeoAnalysis: (analysis: any) => void;
|
||||
setSeoMetadata: (metadata: any) => void;
|
||||
setSections: (sections: Record<string, string>) => void;
|
||||
setSelectedTitle: (title: string | null) => void;
|
||||
setContinuityRefresh: (timestamp: number) => void;
|
||||
setFlowAnalysisCompleted: (completed: boolean) => void;
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
}
|
||||
|
||||
export const useSEOManager = ({
|
||||
sections,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setContentConfirmed,
|
||||
setSeoAnalysis,
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
}: UseSEOManagerProps) => {
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
|
||||
const lastSEOModalOpenRef = useRef<number>(0);
|
||||
|
||||
// Helper: run same checks as analyzeSEO and open modal
|
||||
const runSEOAnalysisDirect = useCallback((): string => {
|
||||
const hasSections = !!sections && Object.keys(sections).length > 0;
|
||||
// Check if sections have actual content (not just empty strings)
|
||||
let sectionsWithContent = hasSections ? Object.values(sections).filter(c => c && c.trim().length > 0) : [];
|
||||
let hasValidContent = sectionsWithContent.length > 0;
|
||||
|
||||
// If sections don't exist in state, check cache (similar to how content generation checks cache)
|
||||
if (!hasValidContent && outline && outline.length > 0) {
|
||||
try {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
if (cachedContent && Object.keys(cachedContent).length > 0) {
|
||||
sectionsWithContent = Object.values(cachedContent).filter(c => c && c.trim().length > 0);
|
||||
hasValidContent = sectionsWithContent.length > 0;
|
||||
if (hasValidContent) {
|
||||
debug.log('[BlogWriter] Using cached content for SEO analysis', { sections: Object.keys(cachedContent).length });
|
||||
// Update sections state with cached content
|
||||
setSections(cachedContent);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug.log('[BlogWriter] Error checking cache for SEO analysis', e);
|
||||
}
|
||||
}
|
||||
|
||||
const hasResearch = !!research && !!(research as any).keyword_analysis;
|
||||
|
||||
if (!hasValidContent) {
|
||||
return "No blog content available for SEO analysis. Please generate content first. Content generation may still be in progress - please wait for it to complete.";
|
||||
}
|
||||
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
|
||||
// Prevent rapid re-opens
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
|
||||
return "SEO analysis is already open.";
|
||||
}
|
||||
|
||||
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
|
||||
if (!contentConfirmed) {
|
||||
setContentConfirmed(true);
|
||||
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
|
||||
}
|
||||
|
||||
setSeoRecommendationsApplied(false);
|
||||
if (!isSEOAnalysisModalOpen) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
lastSEOModalOpenRef.current = now;
|
||||
debug.log('[BlogWriter] SEO modal opened (direct)');
|
||||
}
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}, [sections, research, outline, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed, setSections]);
|
||||
|
||||
const handleApplySeoRecommendations = useCallback(async (
|
||||
recommendations: BlogSEOActionableRecommendation[]
|
||||
) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
const existingContentMap = buildExistingContentMap(sections || {});
|
||||
const emptyMap = new Map<string, string>();
|
||||
|
||||
const sectionPayload = outline.map((section, index) => {
|
||||
const existingMatch = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
[],
|
||||
emptyMap,
|
||||
emptyMap,
|
||||
existingContentMap
|
||||
);
|
||||
const payloadContentRaw = existingMatch.content ?? sections?.[section?.id] ?? '';
|
||||
const payloadContent = payloadContentRaw !== undefined && payloadContentRaw !== null ? String(payloadContentRaw) : '';
|
||||
const rawIdentifier = section?.id || section?.section_id || section?.sectionId || section?.sectionID || `section_${index + 1}`;
|
||||
const identifier = String(rawIdentifier).trim();
|
||||
|
||||
return {
|
||||
id: identifier,
|
||||
heading: section.heading,
|
||||
content: payloadContent,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
sections: sectionPayload,
|
||||
outline,
|
||||
research: (research as any) || {},
|
||||
recommendations,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to apply recommendations.');
|
||||
}
|
||||
|
||||
if (!response.sections || !Array.isArray(response.sections)) {
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
|
||||
|
||||
const normalizedSections: Record<string, string> = {};
|
||||
const sectionKeysForCache: string[] = [];
|
||||
|
||||
outline.forEach((section, index) => {
|
||||
const { content: resolvedContent, matchedKey } = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
response.sections,
|
||||
responseById,
|
||||
responseByHeading,
|
||||
existingContentMap
|
||||
);
|
||||
|
||||
const finalContent = (resolvedContent ?? '').trim();
|
||||
const contentToUse = finalContent || '';
|
||||
const primaryKey = getPrimaryKeyForOutlineSection(section, index);
|
||||
|
||||
normalizedSections[primaryKey] = contentToUse;
|
||||
sectionKeysForCache.push(primaryKey);
|
||||
});
|
||||
|
||||
const uniqueSectionKeys = Array.from(new Set(sectionKeysForCache));
|
||||
|
||||
if (uniqueSectionKeys.length === 0) {
|
||||
throw new Error('No valid sections received from SEO recommendations application.');
|
||||
}
|
||||
|
||||
const sectionsWithContent = Object.values(normalizedSections).filter(c => c && c.trim().length > 0);
|
||||
if (sectionsWithContent.length === 0) {
|
||||
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
|
||||
}
|
||||
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections normalized', {
|
||||
sectionCount: uniqueSectionKeys.length,
|
||||
sectionsWithContent: sectionsWithContent.length,
|
||||
sectionKeys: uniqueSectionKeys,
|
||||
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
setSections(normalizedSections);
|
||||
|
||||
try {
|
||||
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
|
||||
} catch (cacheError) {
|
||||
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
|
||||
}
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
|
||||
if (response.title && response.title !== selectedTitle) {
|
||||
setSelectedTitle(response.title);
|
||||
}
|
||||
|
||||
if (response.applied) {
|
||||
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: response.applied } : prev);
|
||||
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
|
||||
}
|
||||
|
||||
// Mark recommendations as applied (this will trigger phase navigation check)
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
setSeoAnalysis(analysis);
|
||||
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
|
||||
}, [setSeoAnalysis]);
|
||||
|
||||
// Handle SEO modal close - mark SEO phase as done if not already marked
|
||||
const handleSEOModalClose = useCallback(() => {
|
||||
// Mark SEO phase as done when modal closes (even without applying recommendations)
|
||||
if (!seoAnalysis) {
|
||||
// Set a minimal valid seoAnalysis object to mark phase as complete
|
||||
setSeoAnalysis({
|
||||
success: true,
|
||||
overall_score: 0,
|
||||
category_scores: {},
|
||||
analysis_summary: {
|
||||
overall_grade: 'N/A',
|
||||
status: 'Skipped',
|
||||
strongest_category: 'N/A',
|
||||
weakest_category: 'N/A',
|
||||
key_strengths: [],
|
||||
key_weaknesses: [],
|
||||
ai_summary: 'SEO analysis was skipped by user'
|
||||
},
|
||||
actionable_recommendations: [],
|
||||
generated_at: new Date().toISOString()
|
||||
});
|
||||
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
|
||||
}
|
||||
setIsSEOAnalysisModalOpen(false);
|
||||
debug.log('[BlogWriter] SEO modal closed');
|
||||
}, [seoAnalysis, setSeoAnalysis]);
|
||||
|
||||
// Mark SEO phase as completed when recommendations are applied
|
||||
useEffect(() => {
|
||||
if (seoRecommendationsApplied && seoAnalysis) {
|
||||
// SEO phase is considered complete when recommendations are applied
|
||||
// But stay in SEO phase to show updated content
|
||||
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content (override auto-progression)
|
||||
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
|
||||
}
|
||||
}
|
||||
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
|
||||
|
||||
const confirmBlogContent = useCallback(() => {
|
||||
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
setSeoRecommendationsApplied(false);
|
||||
navigateToPhase('seo');
|
||||
setTimeout(() => {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
|
||||
}, 0);
|
||||
return "✅ Blog content has been confirmed! Running SEO analysis now.";
|
||||
}, [setContentConfirmed, navigateToPhase]);
|
||||
|
||||
return {
|
||||
isSEOAnalysisModalOpen,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
isSEOMetadataModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
seoRecommendationsApplied,
|
||||
setSeoRecommendationsApplied,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
handleApplySeoRecommendations,
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
};
|
||||
};
|
||||
|
||||
export type SEOManagerReturn = ReturnType<typeof useSEOManager>;
|
||||
|
||||
178
frontend/src/components/BlogWriter/ContinuityBadge.tsx
Normal file
178
frontend/src/components/BlogWriter/ContinuityBadge.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
refreshToken?: number;
|
||||
disabled?: boolean;
|
||||
flowAnalysisResults?: any;
|
||||
}
|
||||
|
||||
export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disabled = false, flowAnalysisResults }) => {
|
||||
const [metrics, setMetrics] = useState<Record<string, number> | null>(null);
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// If we have flow analysis results, use them instead of API call
|
||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
||||
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||
if (sectionAnalysis) {
|
||||
if (mounted) {
|
||||
setMetrics({
|
||||
flow: sectionAnalysis.flow_score,
|
||||
consistency: sectionAnalysis.consistency_score,
|
||||
progression: sectionAnalysis.progression_score
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if no flow analysis results
|
||||
debug.log('[ContinuityBadge] fetching', { sectionId });
|
||||
blogWriterApi.getContinuity(sectionId)
|
||||
.then(res => {
|
||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.error('[ContinuityBadge] fetch error', error);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||
|
||||
// Show badge even if metrics are null (for debugging)
|
||||
const flow = metrics ? Math.round(((metrics.flow || 0) * 100)) : 0;
|
||||
const consistency = metrics ? Math.round(((metrics.consistency || 0) * 100)) : 0;
|
||||
const progression = metrics ? Math.round(((metrics.progression || 0) * 100)) : 0;
|
||||
|
||||
// Enable badge if we have flow analysis results or metrics
|
||||
const isEnabled = !disabled || (flowAnalysisResults && flowAnalysisResults.sections) || metrics;
|
||||
|
||||
// Enhanced color coding with actionable feedback
|
||||
const getFlowColor = (score: number) => {
|
||||
if (score >= 80) return '#2e7d32'; // Green - Excellent
|
||||
if (score >= 60) return '#f9a825'; // Yellow - Good
|
||||
return '#c62828'; // Red - Needs improvement
|
||||
};
|
||||
|
||||
const getFlowSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🎉 Excellent narrative flow!";
|
||||
if (score >= 60) return "💡 Good flow - try connecting ideas more smoothly";
|
||||
return "🔧 Consider adding transitions between paragraphs";
|
||||
};
|
||||
|
||||
const getConsistencySuggestion = (score: number) => {
|
||||
if (score >= 80) return "✨ Consistent tone and style";
|
||||
if (score >= 60) return "📝 Good consistency - maintain your voice";
|
||||
return "🎯 Work on maintaining consistent tone throughout";
|
||||
};
|
||||
|
||||
const getProgressionSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🚀 Great logical progression!";
|
||||
if (score >= 60) return "📈 Good progression - build on previous points";
|
||||
return "🔗 Strengthen connections between ideas";
|
||||
};
|
||||
|
||||
const color = getFlowColor(flow);
|
||||
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<span
|
||||
title={!isEnabled ? 'Flow analysis disabled - use Copilot to enable' : (metrics ? `Flow ${flow}%` : 'Flow metrics not available')}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
color: !isEnabled ? '#999' : (metrics ? color : '#666'),
|
||||
border: `1px solid ${!isEnabled ? '#ddd' : (metrics ? color : '#ccc')}`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 10,
|
||||
background: !isEnabled ? '#f5f5f5' : 'transparent',
|
||||
cursor: !isEnabled ? 'not-allowed' : 'default',
|
||||
opacity: !isEnabled ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{!isEnabled ? 'Flow --' : (metrics ? `Flow ${flow}%` : 'Flow --')}
|
||||
</span>
|
||||
|
||||
{hover && isEnabled && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '150%',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
minWidth: 280,
|
||||
maxWidth: 320,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12, color: '#1a1a1a' }}>
|
||||
📊 Content Quality Analysis
|
||||
</div>
|
||||
|
||||
{/* Flow Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Flow</span>
|
||||
<span style={{ color: getFlowColor(flow), fontWeight: 600 }}>{flow}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getFlowSuggestion(flow)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consistency Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Consistency</span>
|
||||
<span style={{ color: getFlowColor(consistency), fontWeight: 600 }}>{consistency}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getConsistencySuggestion(consistency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progression Analysis */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Progression</span>
|
||||
<span style={{ color: getFlowColor(progression), fontWeight: 600 }}>{progression}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getProgressionSuggestion(progression)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Quality Indicator */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 8,
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
💡 Hover over other sections to compare quality metrics
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContinuityBadge;
|
||||
|
||||
|
||||
142
frontend/src/components/BlogWriter/CustomOutlineForm.tsx
Normal file
142
frontend/src/components/BlogWriter/CustomOutlineForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface CustomOutlineFormProps {
|
||||
onOutlineCreated?: (outline: any) => void;
|
||||
}
|
||||
|
||||
export const CustomOutlineForm: React.FC<CustomOutlineFormProps> = ({ onOutlineCreated }) => {
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'getCustomOutlineInstructions',
|
||||
description: 'Get custom instructions from user for outline generation',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Custom outline instructions received! Creating your personalized outline...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🎨 Create Custom Outline
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{args.prompt || 'Tell me your specific requirements for the blog outline. What should it focus on? What structure do you prefer?'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Custom Instructions *
|
||||
</label>
|
||||
<textarea
|
||||
value={customInstructions}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
placeholder="e.g., Focus on beginner-friendly explanations, include case studies, emphasize practical applications, create a step-by-step guide format..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', color: '#1976d2', fontSize: '14px' }}>💡 Examples:</h5>
|
||||
<ul style={{ margin: '0', paddingLeft: '20px', fontSize: '13px', color: '#333' }}>
|
||||
<li>"Focus on beginner-friendly explanations with practical examples"</li>
|
||||
<li>"Include case studies and real-world applications"</li>
|
||||
<li>"Create a step-by-step tutorial format"</li>
|
||||
<li>"Emphasize the business benefits and ROI"</li>
|
||||
<li>"Make it more technical and detailed for developers"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customInstructions.trim()) {
|
||||
respond?.(customInstructions.trim());
|
||||
} else {
|
||||
window.alert('Please provide your custom instructions for the outline.');
|
||||
}
|
||||
}}
|
||||
disabled={!customInstructions.trim() || isSubmitting}
|
||||
style={{
|
||||
backgroundColor: customInstructions.trim() ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: customInstructions.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? '⏳ Creating...' : '🚀 Create Custom Outline'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => respond?.('CANCEL')}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
original: string;
|
||||
updated: string;
|
||||
onApply: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
function highlightDiff(a: string, b: string) {
|
||||
// Simple common prefix/suffix highlighting
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||||
let j = 0;
|
||||
while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
|
||||
const aMid = a.substring(i, a.length - j);
|
||||
const bMid = b.substring(i, b.length - j);
|
||||
const aHtml = `${escapeHtml(a.substring(0, i))}<span style="background:#ffe5e5;text-decoration:line-through;">${escapeHtml(aMid)}</span>${escapeHtml(a.substring(a.length - j))}`;
|
||||
const bHtml = `${escapeHtml(b.substring(0, i))}<span style="background:#e6ffed;">${escapeHtml(bMid)}</span>${escapeHtml(b.substring(b.length - j))}`;
|
||||
return { aHtml, bHtml };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<Props> = ({ original, updated, onApply, onDiscard }) => {
|
||||
const { aHtml, bHtml } = highlightDiff(original, updated);
|
||||
return (
|
||||
<div style={{ border: '1px solid #ddd', padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Preview Changes</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, background: '#fafafa', padding: 8 }} dangerouslySetInnerHTML={{ __html: aHtml }} />
|
||||
<div style={{ flex: 1, background: '#f5fff5', padding: 8 }} dangerouslySetInnerHTML={{ __html: bHtml }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button onClick={onApply}>Apply</button>
|
||||
<button onClick={onDiscard}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreview;
|
||||
|
||||
|
||||
200
frontend/src/components/BlogWriter/EnhancedOutlineActions.tsx
Normal file
200
frontend/src/components/BlogWriter/EnhancedOutlineActions.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface EnhancedOutlineActionsProps {
|
||||
outline: BlogOutlineSection[];
|
||||
onOutlineUpdated: (outline: BlogOutlineSection[]) => void;
|
||||
}
|
||||
|
||||
export const EnhancedOutlineActions: React.FC<EnhancedOutlineActionsProps> = ({
|
||||
outline,
|
||||
onOutlineUpdated
|
||||
}) => {
|
||||
// Enhanced Outline Actions
|
||||
useCopilotActionTyped({
|
||||
name: 'enhanceSection',
|
||||
description: 'Enhance a specific outline section with AI improvements',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'ID of the section to enhance', required: true },
|
||||
{ name: 'focus', type: 'string', description: 'Enhancement focus (SEO, engagement, depth, etc.)', required: false }
|
||||
],
|
||||
handler: async ({ sectionId, focus = 'general improvement' }: { sectionId: string; focus?: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found' };
|
||||
|
||||
try {
|
||||
const enhancedSection = await blogWriterApi.enhanceSection(section, focus);
|
||||
onOutlineUpdated(outline.map(s => s.id === sectionId ? enhancedSection : s));
|
||||
return {
|
||||
success: true,
|
||||
message: `Enhanced section "${section.heading}" with focus on ${focus}`,
|
||||
enhanced_section: enhancedSection
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Enhancement failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #9c27b0',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#9c27b0' }}>✨ Enhancing Section</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section content and structure...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Generating enhanced subheadings and key points...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for better engagement and SEO...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeOutline',
|
||||
description: 'Optimize entire outline for better flow, SEO, and engagement',
|
||||
parameters: [
|
||||
{ name: 'focus', type: 'string', description: 'Optimization focus (flow, SEO, engagement, etc.)', required: false }
|
||||
],
|
||||
handler: async ({ focus = 'general optimization' }: { focus?: string }) => {
|
||||
if (outline.length === 0) return { success: false, message: 'No outline to optimize' };
|
||||
|
||||
try {
|
||||
const optimizedOutline = await blogWriterApi.optimizeOutline({ outline }, focus);
|
||||
onOutlineUpdated(optimizedOutline.outline);
|
||||
return {
|
||||
success: true,
|
||||
message: `Optimized outline with focus on ${focus}`,
|
||||
optimized_outline: optimizedOutline.outline
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Optimization failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #ff9800',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#ff9800' }}>🎯 Optimizing Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing outline structure and flow...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Optimizing headings for SEO and engagement...</p>
|
||||
<p style={{ margin: '0' }}>• Improving narrative progression and reader experience...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'rebalanceOutline',
|
||||
description: 'Rebalance word count distribution across outline sections',
|
||||
parameters: [
|
||||
{ name: 'targetWords', type: 'number', description: 'Target total word count', required: false }
|
||||
],
|
||||
handler: async ({ targetWords = 1500 }: { targetWords?: number }) => {
|
||||
if (outline.length === 0) return { success: false, message: 'No outline to rebalance' };
|
||||
|
||||
try {
|
||||
const rebalancedOutline = await blogWriterApi.rebalanceOutline({ outline }, targetWords);
|
||||
onOutlineUpdated(rebalancedOutline.outline);
|
||||
return {
|
||||
success: true,
|
||||
message: `Rebalanced outline for ${targetWords} words`,
|
||||
rebalanced_outline: rebalancedOutline.outline
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Rebalancing failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #4caf50',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#4caf50' }}>⚖️ Rebalancing Word Counts</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Calculating optimal word distribution...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Adjusting section word counts...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring balanced content structure...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit actions, no UI
|
||||
};
|
||||
841
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
841
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
@@ -0,0 +1,841 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage, blogWriterApi } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
onRefine: (operation: string, sectionId?: string, payload?: any) => void;
|
||||
research?: any; // Research data for context
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
outline,
|
||||
onRefine,
|
||||
research,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
sectionImages = {},
|
||||
setSectionImages
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState('');
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
|
||||
const toggleExpanded = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const handleRename = (sectionId: string, newHeading: string) => {
|
||||
if (newHeading.trim()) {
|
||||
onRefine('rename', sectionId, { heading: newHeading.trim() });
|
||||
}
|
||||
setEditingSection(null);
|
||||
};
|
||||
|
||||
const handleMove = (sectionId: string, direction: 'up' | 'down') => {
|
||||
onRefine('move', sectionId, { direction });
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
if (newSectionData.heading.trim()) {
|
||||
const subheadings = newSectionData.subheadings
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
const keyPoints = newSectionData.key_points
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
onRefine('add', undefined, {
|
||||
heading: newSectionData.heading.trim(),
|
||||
subheadings,
|
||||
key_points: keyPoints,
|
||||
target_words: newSectionData.target_words
|
||||
});
|
||||
|
||||
setNewSectionData({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
setShowAddSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefineOutline = async () => {
|
||||
if (!refineFeedback.trim()) {
|
||||
alert('Please provide feedback on how you would like to refine the outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
// Use the parent's onRefine callback which handles the API call and state update
|
||||
// The callback expects: operation, sectionId, payload
|
||||
await onRefine('refine', undefined, { feedback: refineFeedback.trim() });
|
||||
|
||||
setRefineFeedback('');
|
||||
setShowRefineModal(false);
|
||||
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
`;
|
||||
toast.textContent = '✅ Outline refined successfully!';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => document.body.removeChild(toast), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to refine outline:', error);
|
||||
alert('Failed to refine outline. Please try again.');
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalWords = () => {
|
||||
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{imageModalState.open && (
|
||||
<ImageGeneratorModal
|
||||
isOpen={imageModalState.open}
|
||||
onClose={() => setImageModalState({ open: false })}
|
||||
defaultPrompt={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return sec?.heading || '';
|
||||
})()}
|
||||
context={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return {
|
||||
title: sec?.heading,
|
||||
section: sec,
|
||||
outline,
|
||||
research,
|
||||
sectionId: imageModalState.sectionId
|
||||
};
|
||||
})()}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
if (sectionId && setSectionImages) {
|
||||
setSectionImages((prev: Record<string, string>) => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '20px' }}>
|
||||
📋 Blog Outline
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{outline.length} sections • {getTotalWords()} words total
|
||||
</p>
|
||||
</div>
|
||||
{/* Intelligence Chips inline with title */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<OutlineIntelligenceChips
|
||||
sections={outline}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setShowRefineModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
title="Refine the outline structure based on your feedback"
|
||||
>
|
||||
🔧 Refine Outline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add Section Form */}
|
||||
{showAddSection && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Add New Section</h3>
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Section Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionData.heading}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, heading: e.target.value})}
|
||||
placeholder="Enter section title..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Subheadings (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.subheadings}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, subheadings: e.target.value})}
|
||||
placeholder="Subheading 1 Subheading 2 Subheading 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Key Points (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.key_points}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, key_points: e.target.value})}
|
||||
placeholder="Key point 1 Key point 2 Key point 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Words
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSectionData.target_words}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, target_words: parseInt(e.target.value) || 300})}
|
||||
min="100"
|
||||
max="2000"
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleAddSection}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(false)}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outline Sections */}
|
||||
<div style={{ padding: '0' }}>
|
||||
{outline.map((section, index) => (
|
||||
<div key={section.id} style={{
|
||||
borderBottom: index < outline.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSection(section.id)}
|
||||
onMouseLeave={() => setHoveredSection(null)}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{editingSection === section.id ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={section.heading}
|
||||
onBlur={(e) => handleRename(section.id, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(section.id, e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1
|
||||
}}>
|
||||
{section.heading}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.target_words || 300} words
|
||||
</span>
|
||||
|
||||
{section.references && section.references.length > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.references.length} sources
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSection(section.id);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
title="Generate Image"
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
🖼️ Generate Image
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === 0 ? '#ccc' : '#666',
|
||||
opacity: index === 0 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬆️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'down');
|
||||
}}
|
||||
disabled={index === outline.length - 1}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === outline.length - 1 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === outline.length - 1 ? '#ccc' : '#666',
|
||||
opacity: index === outline.length - 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`Are you sure you want to remove "${section.heading}"?`)) {
|
||||
onRefine('remove', section.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #f44336',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#f44336'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
transform: expandedSections.has(section.id) ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📝 Subheadings
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.subheadings.map((subheading, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{subheading}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Points */}
|
||||
{section.key_points && section.key_points.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 Key Points
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.key_points.map((point, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{section.keywords && section.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 SEO Keywords
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{section.keywords.map((keyword, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{section.references && section.references.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📚 Sources ({section.references.length})
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{section.references.map((ref, i) => (
|
||||
<div key={i} style={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '500', marginBottom: '2px' }}>
|
||||
{ref.title}
|
||||
</div>
|
||||
<div style={{ color: '#999' }}>
|
||||
Credibility: {Math.round((ref.credibility_score || 0.8) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Image Display */}
|
||||
{sectionImages[section.id] && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🖼️ Generated Image
|
||||
</h4>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white'
|
||||
}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImages[section.id]}`}
|
||||
alt={`Generated image for ${section.heading}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Generate Image for this section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
💡 Tip: Click on any section to expand and see details. Use the controls to reorder, edit, or remove sections.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Total: {getTotalWords()} words
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refine Outline Modal */}
|
||||
{showRefineModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
🔧 Refine Outline
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Provide feedback on how you'd like to improve the outline structure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
Your Feedback
|
||||
</label>
|
||||
<textarea
|
||||
value={refineFeedback}
|
||||
onChange={(e) => setRefineFeedback(e.target.value)}
|
||||
placeholder="E.g., Add a section about best practices, merge sections 2 and 3, expand the introduction..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRefineModal(false);
|
||||
setRefineFeedback('');
|
||||
}}
|
||||
disabled={isRefining}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefineOutline}
|
||||
disabled={isRefining || !refineFeedback.trim()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: isRefining || !refineFeedback.trim() ? '#9ca3af' : '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining || !refineFeedback.trim() ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{isRefining ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>Refining...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔧</span>
|
||||
<span>Refine Outline</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineEditor;
|
||||
469
frontend/src/components/BlogWriter/EnhancedOutlineInsights.tsx
Normal file
469
frontend/src/components/BlogWriter/EnhancedOutlineInsights.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface GroundingInsights {
|
||||
confidence_analysis?: {
|
||||
average_confidence: number;
|
||||
high_confidence_sources_count: number;
|
||||
confidence_distribution: { high: number; medium: number; low: number };
|
||||
};
|
||||
authority_analysis?: {
|
||||
average_authority_score: number;
|
||||
high_authority_sources: Array<{ title: string; url: string; score: number }>;
|
||||
};
|
||||
content_relationships?: {
|
||||
related_concepts: string[];
|
||||
content_gaps: string[];
|
||||
concept_coverage_score: number;
|
||||
};
|
||||
search_intent_insights?: {
|
||||
primary_intent: string;
|
||||
user_questions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceMappingStats {
|
||||
total_sources_mapped: number;
|
||||
coverage_percentage: number;
|
||||
average_relevance_score: number;
|
||||
high_confidence_mappings: number;
|
||||
}
|
||||
|
||||
interface OptimizationResults {
|
||||
overall_quality_score: number;
|
||||
improvements_made: string[];
|
||||
optimization_focus: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: BlogOutlineSection[];
|
||||
groundingInsights?: GroundingInsights;
|
||||
sourceMappingStats?: SourceMappingStats;
|
||||
optimizationResults?: OptimizationResults;
|
||||
researchCoverage?: {
|
||||
sources_utilized: number;
|
||||
content_gaps_identified: number;
|
||||
competitive_advantages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const EnhancedOutlineInsights: React.FC<Props> = ({
|
||||
sections,
|
||||
groundingInsights,
|
||||
sourceMappingStats,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleInsight = (insightType: string) => {
|
||||
const newExpanded = new Set(expandedInsights);
|
||||
if (newExpanded.has(insightType)) {
|
||||
newExpanded.delete(insightType);
|
||||
} else {
|
||||
newExpanded.add(insightType);
|
||||
}
|
||||
setExpandedInsights(newExpanded);
|
||||
};
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
margin: '20px 0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
|
||||
🧠 Outline Intelligence & Insights
|
||||
</h3>
|
||||
<span style={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
{sections.length} sections analyzed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px' }}>
|
||||
{/* Research Coverage */}
|
||||
{researchCoverage && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('research') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('research')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>📊</span>
|
||||
<span style={{ fontWeight: '600' }}>Research Data Utilization</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('research') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('research') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Utilized</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ff9800' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Content Gaps Identified</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Competitive Advantages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Key Advantages:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Mapping Intelligence */}
|
||||
{sourceMappingStats && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('mapping') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('mapping')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🔗</span>
|
||||
<span style={{ fontWeight: '600' }}>Source Mapping Intelligence</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('mapping') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('mapping') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Mapped</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) }}>
|
||||
{sourceMappingStats.coverage_percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Coverage</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score) }}>
|
||||
{(sourceMappingStats.average_relevance_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Relevance</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grounding Insights */}
|
||||
{groundingInsights && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('grounding') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('grounding')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🧠</span>
|
||||
<span style={{ fontWeight: '600' }}>Grounding Metadata Insights</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('grounding') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('grounding') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Confidence Analysis</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) }}>
|
||||
{(groundingInsights.confidence_analysis.average_confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Confidence</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence Sources</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Authority Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) }}>
|
||||
{(groundingInsights.authority_analysis.average_authority_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Authority</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Top Authority Sources:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 3).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{source.title.substring(0, 30)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Content Relationships</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) }}>
|
||||
{(groundingInsights.content_relationships.concept_coverage_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Concept Coverage</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Related Concepts:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 5).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Search Intent Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Primary Intent</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>User Questions:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 3).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{question.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optimization Results */}
|
||||
{optimizationResults && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('optimization') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('optimization')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🎯</span>
|
||||
<span style={{ fontWeight: '600' }}>Optimization Results</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('optimization') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('optimization') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Quality Assessment</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Overall Quality</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Focus Area</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Improvements Made:</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineInsights;
|
||||
707
frontend/src/components/BlogWriter/EnhancedTitleSelector.tsx
Normal file
707
frontend/src/components/BlogWriter/EnhancedTitleSelector.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../services/blogWriterApi';
|
||||
|
||||
interface EnhancedTitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
selectedTitle?: string;
|
||||
onTitleSelect: (title: string) => void;
|
||||
onCustomTitle?: (title: string) => void;
|
||||
sections: BlogOutlineSection[];
|
||||
researchTitles?: string[];
|
||||
aiGeneratedTitles?: string[];
|
||||
research?: BlogResearchResponse;
|
||||
onTitlesGenerated?: (titles: string[]) => void;
|
||||
}
|
||||
|
||||
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
onTitleSelect,
|
||||
onCustomTitle,
|
||||
sections,
|
||||
researchTitles = [],
|
||||
aiGeneratedTitles = [],
|
||||
research,
|
||||
onTitlesGenerated
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
|
||||
const [generationProgress, setGenerationProgress] = useState<string>('');
|
||||
|
||||
const handleTitleSelect = (title: string) => {
|
||||
onTitleSelect(title);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleCustomTitleSubmit = () => {
|
||||
if (customTitle.trim() && onCustomTitle) {
|
||||
onCustomTitle(customTitle.trim());
|
||||
setCustomTitle('');
|
||||
setShowModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSEOTitles = async () => {
|
||||
if (!research || !sections.length || isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenerationProgress('Analyzing research data and outline structure...');
|
||||
|
||||
try {
|
||||
const keywordAnalysis = research.keyword_analysis || {};
|
||||
const primaryKeywords = keywordAnalysis.primary || [];
|
||||
const secondaryKeywords = keywordAnalysis.secondary || [];
|
||||
const contentAngles = research.suggested_angles || [];
|
||||
const searchIntent = keywordAnalysis.search_intent || 'informational';
|
||||
|
||||
// Simulate progress updates
|
||||
setTimeout(() => setGenerationProgress('Extracting keywords and content angles...'), 500);
|
||||
setTimeout(() => setGenerationProgress('Generating SEO-optimized titles with AI...'), 1500);
|
||||
|
||||
const result = await blogWriterApi.generateSEOTitles({
|
||||
research,
|
||||
outline: sections,
|
||||
primary_keywords: primaryKeywords,
|
||||
secondary_keywords: secondaryKeywords,
|
||||
content_angles: contentAngles,
|
||||
search_intent: searchIntent,
|
||||
word_count: sections.reduce((sum, s) => sum + (s.target_words || 0), 0)
|
||||
});
|
||||
|
||||
setGenerationProgress('Finalizing titles...');
|
||||
|
||||
if (result.success && result.titles) {
|
||||
setTimeout(() => {
|
||||
setGeneratedTitles(result.titles);
|
||||
setGenerationProgress('');
|
||||
if (onTitlesGenerated) {
|
||||
onTitlesGenerated(result.titles);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SEO titles:', error);
|
||||
setGenerationProgress('');
|
||||
alert('Failed to generate SEO titles. Please try again.');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsGenerating(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const getSectionSummary = () => {
|
||||
return sections.map(section => ({
|
||||
title: section.heading,
|
||||
wordCount: section.target_words || 0,
|
||||
subheadings: section.subheadings.length,
|
||||
keyPoints: section.key_points.length
|
||||
}));
|
||||
};
|
||||
|
||||
const sectionSummary = getSectionSummary();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Title Display */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#333', fontSize: '18px' }}>
|
||||
📝 Blog Title
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '0',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
{(selectedTitle || 'No title selected').length > 150
|
||||
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
|
||||
: (selectedTitle || 'No title selected')}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
title="Open title suggestions. Click 'Generate 5 SEO-Optimized Titles' in the modal to create premium titles (50-65 characters) optimized for search engines using your research data and outline."
|
||||
>
|
||||
✨ ALwrity it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Selection Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
✨ ALwrity Title Suggestions
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Choose from research-based content angles, AI-generated titles, or create your own
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generate SEO Titles Button */}
|
||||
{research && sections.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={handleGenerateSEOTitles}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
backgroundColor: isGenerating ? '#9ca3af' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>{generationProgress || 'Generating SEO Titles...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✨</span>
|
||||
<span>Generate 5 SEO-Optimized Titles</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isGenerating && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: '2px',
|
||||
marginTop: '12px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
backgroundColor: '#1976d2',
|
||||
borderRadius: '2px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
width: '100%'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && generationProgress && (
|
||||
<p style={{
|
||||
margin: '8px 0 0 0',
|
||||
color: '#6b7280',
|
||||
fontSize: '13px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{generationProgress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Options */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{/* Generated SEO Titles */}
|
||||
{generatedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#dcfce7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🎯
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
SEO-Optimized Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Premium titles optimized for search engines (50-65 characters)
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#16a34a',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{generatedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{generatedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`seo-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Research Content Angles */}
|
||||
{researchTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🔍
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Research Content Angles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Titles derived from your research data and content angles
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{researchTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{researchTitles.map((title, index) => (
|
||||
<button
|
||||
key={`research-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #1976d2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0f9ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI-Generated Titles */}
|
||||
{aiGeneratedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f3e5f5',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
AI-Generated Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Creative titles generated by AI based on your research
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{aiGeneratedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{aiGeneratedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`ai-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #7b1fa2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#faf5ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Title Input */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
✏️
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Custom Title
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Create your own unique title
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Enter your custom title..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '15px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCustomTitleSubmit()}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(25, 118, 210, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomTitleSubmit}
|
||||
disabled={!customTitle.trim()}
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
backgroundColor: customTitle.trim() ? '#1976d2' : '#d1d5db',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: customTitle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: '120px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Use Title
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Information */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginTop: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
|
||||
📋 Current Outline Summary
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Details */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{sectionSummary.map((section, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<span>{section.wordCount} words</span>
|
||||
<span>{section.subheadings} subheadings</span>
|
||||
<span>{section.keyPoints} key points</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
marginTop: '24px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTitleSelector;
|
||||
73
frontend/src/components/BlogWriter/HallucinationChecker.tsx
Normal file
73
frontend/src/components/BlogWriter/HallucinationChecker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import DiffPreview from './DiffPreview';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface HallucinationCheckerProps {
|
||||
buildFullMarkdown: () => string;
|
||||
buildUpdatedMarkdownForClaim: (claimText: string, supportingUrl?: string) => {
|
||||
original: string;
|
||||
updated: string;
|
||||
updatedMarkdown: string;
|
||||
};
|
||||
applyClaimFix: (claimText: string, supportingUrl?: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const HallucinationChecker: React.FC<HallucinationCheckerProps> = ({
|
||||
buildFullMarkdown,
|
||||
buildUpdatedMarkdownForClaim,
|
||||
applyClaimFix
|
||||
}) => {
|
||||
const [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'runHallucinationCheck',
|
||||
description: 'Run hallucination detector on full draft and view claims',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await apiClient.post('/api/blog/quality/hallucination-check', { text: content });
|
||||
const data = res.data;
|
||||
setHallucinationResult(data);
|
||||
return { success: true, total_claims: data?.total_claims };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, result }: any) => {
|
||||
if (!result) return null;
|
||||
const claims = hallucinationResult?.claims || [];
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Hallucination Check</div>
|
||||
<div>Total claims: {hallucinationResult?.total_claims ?? 0}</div>
|
||||
<ul>
|
||||
{claims.slice(0, 5).map((c: any, i: number) => {
|
||||
const supporting = (c.supporting_sources && c.supporting_sources[0]?.url) || undefined;
|
||||
const { original, updated } = buildUpdatedMarkdownForClaim(c.text, supporting);
|
||||
return (
|
||||
<li key={i} style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 4 }}>[{c.assessment}] {c.text} (conf: {Math.round((c.confidence || 0)*100)/100})</div>
|
||||
{original && updated ? (
|
||||
<DiffPreview
|
||||
original={original}
|
||||
updated={updated}
|
||||
onApply={() => { applyClaimFix(c.text, supporting); respond?.('applied'); }}
|
||||
onDiscard={() => { respond?.('discarded'); }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>No matching sentence found for preview.</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={() => respond?.('ack')}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default HallucinationChecker;
|
||||
27
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
27
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import ResearchPollingHandler from './ResearchPollingHandler';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface KeywordInputFormProps {
|
||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
// This component is now a lightweight wrapper
|
||||
// The actual keyword input form is handled by ResearchAction component
|
||||
// Polling is handled by ResearchPollingHandler in ResearchAction
|
||||
// This component exists for backward compatibility but doesn't create unnecessary polling hooks
|
||||
|
||||
// Note: If onTaskStart is called, it should use the researchPolling from parent
|
||||
// (passed via CopilotKitComponents), not create a new polling instance here
|
||||
|
||||
return null; // No UI needed - ResearchAction handles everything
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
113
frontend/src/components/BlogWriter/ManualContentButton.tsx
Normal file
113
frontend/src/components/BlogWriter/ManualContentButton.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, CircularProgress } from '@mui/material';
|
||||
import { mediumBlogApi } from '../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface ManualContentButtonProps {
|
||||
/**
|
||||
* The confirmed outline sections
|
||||
*/
|
||||
outline: BlogOutlineSection[];
|
||||
/**
|
||||
* The research data
|
||||
*/
|
||||
research: BlogResearchResponse;
|
||||
/**
|
||||
* Blog title (optional)
|
||||
*/
|
||||
blogTitle?: string;
|
||||
/**
|
||||
* Existing sections content (optional)
|
||||
*/
|
||||
sections?: Record<string, string>;
|
||||
/**
|
||||
* Callback when content generation starts
|
||||
*/
|
||||
onGenerationStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual content generation button that works independently of CopilotKit
|
||||
* Triggers medium blog generation via mediumBlogApi
|
||||
*/
|
||||
export const ManualContentButton: React.FC<ManualContentButtonProps> = ({
|
||||
outline,
|
||||
research,
|
||||
blogTitle,
|
||||
sections,
|
||||
onGenerationStart,
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please confirm an outline first before generating content.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
alert('Research data is required for content generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
outline,
|
||||
research,
|
||||
title: blogTitle || outline[0]?.heading || 'Blog Post',
|
||||
existing_sections: sections || {},
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
|
||||
if (task_id) {
|
||||
onGenerationStart?.(task_id);
|
||||
} else {
|
||||
throw new Error('Failed to start content generation - no task ID returned');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
alert(`Content generation failed: ${errorMessage}`);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Generate Blog Content</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Generate full content for all sections in your confirmed outline.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
disabled={!outline || outline.length === 0 || !research || isGenerating}
|
||||
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating Content...' : '📝 Generate Content'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualContentButton;
|
||||
|
||||
111
frontend/src/components/BlogWriter/ManualOutlineButton.tsx
Normal file
111
frontend/src/components/BlogWriter/ManualOutlineButton.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, CircularProgress } from '@mui/material';
|
||||
|
||||
interface ManualOutlineButtonProps {
|
||||
/**
|
||||
* Ref to OutlineGenerator component with generateNow() method
|
||||
*/
|
||||
outlineGenRef: React.RefObject<{
|
||||
generateNow: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
task_id?: string;
|
||||
cached?: boolean;
|
||||
outline?: any[];
|
||||
title_options?: string[];
|
||||
}>
|
||||
}>;
|
||||
/**
|
||||
* Whether research is available (required for outline generation)
|
||||
*/
|
||||
hasResearch: boolean;
|
||||
/**
|
||||
* Callback when outline generation starts
|
||||
*/
|
||||
onGenerationStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual outline generation button that works independently of CopilotKit
|
||||
* Calls the generateNow() method from OutlineGenerator ref
|
||||
*/
|
||||
export const ManualOutlineButton: React.FC<ManualOutlineButtonProps> = ({
|
||||
outlineGenRef,
|
||||
hasResearch,
|
||||
onGenerationStart,
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!hasResearch) {
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!outlineGenRef.current) {
|
||||
alert('Outline generator is not available. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
|
||||
if (result.success) {
|
||||
if (result.cached && result.outline) {
|
||||
// Handle cached result - outline is already available, no need to poll
|
||||
console.log('[ManualOutlineButton] Cached outline used', { sections: result.outline.length });
|
||||
// The outline should be set by the parent component handling the cache
|
||||
} else if (result.task_id) {
|
||||
onGenerationStart?.(result.task_id);
|
||||
}
|
||||
} else {
|
||||
setError(result.message || 'Failed to generate outline');
|
||||
alert(result.message || 'Failed to generate outline. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
alert(`Outline generation failed: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Create Your Outline</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Generate an AI-powered outline based on your research.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
disabled={!hasResearch || isGenerating}
|
||||
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating Outline...' : '🧩 Generate Outline'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualOutlineButton;
|
||||
|
||||
184
frontend/src/components/BlogWriter/ManualResearchForm.tsx
Normal file
184
frontend/src/components/BlogWriter/ManualResearchForm.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
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 = useResearchPolling({
|
||||
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 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);
|
||||
} catch (error) {
|
||||
console.error('Research failed:', error);
|
||||
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
opacity: isSubmitting ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
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>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000 words (Comprehensive blog)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
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',
|
||||
opacity: isSubmitting ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? '⏳ Starting Research...' : '🚀 Start Research'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualResearchForm;
|
||||
|
||||
747
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal file
747
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi, mediumBlogApi } from '../../services/blogWriterApi';
|
||||
import { useMediumGenerationPolling } from '../../hooks/usePolling';
|
||||
|
||||
// Simple toast notification function
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
background-color: ${type === 'success' ? '#4caf50' : '#f44336'};
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface OutlineFeedbackFormProps {
|
||||
outline: BlogOutlineSection[];
|
||||
research: BlogResearchResponse;
|
||||
onOutlineConfirmed: () => void;
|
||||
onOutlineRefined: (feedback: string) => void;
|
||||
onMediumGenerationStarted?: (taskId: string) => void;
|
||||
onMediumGenerationTriggered?: () => void;
|
||||
sections?: Record<string, string>;
|
||||
blogTitle?: string;
|
||||
onFlowAnalysisComplete?: (analysis: any) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
|
||||
// Separate component to manage feedback form state
|
||||
const FeedbackForm: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { feedback: string; action: 'refine' | 'confirm' }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [action, setAction] = useState<'refine' | 'confirm'>('refine');
|
||||
const hasValidInput = feedback.trim().length > 0 || action === 'confirm';
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({ feedback: feedback.trim(), action });
|
||||
} else {
|
||||
window.alert('Please provide feedback or confirm the outline.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
📝 Outline Review & Feedback
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please review the generated outline and provide your feedback:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
What would you like to do? *
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="action"
|
||||
value="refine"
|
||||
checked={action === 'refine'}
|
||||
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>🔧 Refine/Edit Outline</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="action"
|
||||
value="confirm"
|
||||
checked={action === 'confirm'}
|
||||
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>✅ Confirm & Generate Content</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action === 'refine' && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
Your Feedback & Suggestions *
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="e.g., Add a section about implementation challenges, Remove the conclusion section, Make the introduction more engaging, Change the order of sections..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '100px',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
autoFocus
|
||||
spellCheck="true"
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
💡 Be specific about what you want to change. The AI will use your feedback to improve the outline.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'confirm' && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #4caf50'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#2e7d32', fontSize: '14px' }}>
|
||||
✅ Ready to generate content! Click "Submit" to proceed with content generation for all sections.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{action === 'refine' ? '🔧 Refine Outline' : '✅ Confirm & Generate Content'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
navigateToPhase,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
onMediumGenerationTriggered,
|
||||
sections,
|
||||
blogTitle,
|
||||
onFlowAnalysisComplete
|
||||
}) => {
|
||||
|
||||
// Refine outline action with HITL
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline based on user feedback',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
handler: async ({ prompt, feedback }: { prompt?: string; feedback?: string }) => {
|
||||
// Validate input
|
||||
if (!feedback || feedback.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please provide specific feedback for outline refinement.',
|
||||
suggestion: 'Try describing what you want to change, add, or remove from the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available for outline refinement.',
|
||||
suggestion: 'Please complete research first before refining the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a refined outline request with user feedback
|
||||
const refineRequest = {
|
||||
research: research,
|
||||
current_outline: outline,
|
||||
user_feedback: feedback.trim(),
|
||||
word_count: 1500
|
||||
};
|
||||
|
||||
// Start async outline refinement
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration(refineRequest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔧 Outline refinement started based on your feedback! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id,
|
||||
next_step_suggestion: 'The outline is being refined based on your feedback. You can monitor progress below.'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline refinement error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Outline refinement failed: ${errorMessage}`,
|
||||
suggestion: 'Try providing more specific feedback or ask me to help clarify your requirements.'
|
||||
};
|
||||
}
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Outline refinement completed! Check the progress below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffc107'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#856404', fontWeight: '500' }}>
|
||||
⏳ Refining outline based on your feedback...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackForm
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
if (formData.action === 'confirm') {
|
||||
onOutlineConfirmed();
|
||||
} else {
|
||||
onOutlineRefined(formData.feedback);
|
||||
}
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Outline confirmation action
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmOutlineAndGenerateContent',
|
||||
description: 'Confirm the outline and mark it as ready for content generation. This does NOT automatically generate content - it only confirms the outline.',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Validate that we have an outline to confirm
|
||||
if (!outline || outline.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No outline available to confirm.',
|
||||
suggestion: 'Please generate an outline first before confirming.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to content phase when outline is confirmed
|
||||
navigateToPhase?.('content');
|
||||
|
||||
onOutlineConfirmed();
|
||||
|
||||
// If research specifies a short/medium blog (<=1000), kick off medium generation
|
||||
const target = Number(
|
||||
research?.keyword_analysis?.blog_length ||
|
||||
(research as any)?.word_count_target ||
|
||||
localStorage.getItem('blog_length_target') ||
|
||||
0
|
||||
);
|
||||
|
||||
if (target && target <= 1000) {
|
||||
// Check cache first (shared utility)
|
||||
const { blogWriterCache } = await import('../../services/blogWriterCache');
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
|
||||
if (cachedContent) {
|
||||
console.log('[OutlineFeedbackForm] Using cached content', { sections: Object.keys(cachedContent).length });
|
||||
// Content is already cached, skip API call
|
||||
return {
|
||||
success: true,
|
||||
message: 'Content is already available from cache.',
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
// Show modal immediately when medium generation is triggered
|
||||
onMediumGenerationTriggered?.();
|
||||
// Build payload for medium generation
|
||||
const payload = {
|
||||
title: (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
|
||||
sections: outline.map(s => ({
|
||||
id: s.id,
|
||||
heading: s.heading,
|
||||
keyPoints: s.key_points,
|
||||
subheadings: s.subheadings,
|
||||
keywords: s.keywords,
|
||||
targetWords: s.target_words,
|
||||
references: s.references,
|
||||
})),
|
||||
globalTargetWords: target,
|
||||
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [], // Use original research keywords for better caching
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
|
||||
// Notify parent to start polling for the medium generation task
|
||||
onMediumGenerationStarted?.(task_id);
|
||||
|
||||
// Poll once immediately to check for immediate failures (e.g., subscription errors)
|
||||
try {
|
||||
const initialStatus = await mediumBlogApi.pollMediumGeneration(task_id);
|
||||
|
||||
// Check if task already failed with subscription error
|
||||
if (initialStatus.status === 'failed' && (initialStatus.error_status === 429 || initialStatus.error_status === 402)) {
|
||||
const errorData = initialStatus.error_data || {};
|
||||
const errorMessage = errorData.message || errorData.error || initialStatus.error || 'Subscription limit exceeded';
|
||||
|
||||
// Return error to CopilotKit so it shows in chat
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Medium generation failed: ${errorMessage}`,
|
||||
error: errorMessage,
|
||||
error_type: 'subscription_limit',
|
||||
provider: errorData.provider || 'unknown',
|
||||
suggestion: 'Please renew your subscription to continue generating content.',
|
||||
action_taken: 'outline_confirmed_medium_generation_failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Task started successfully, continue polling in background
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
|
||||
task_id,
|
||||
action_taken: 'outline_confirmed_medium_generation_started'
|
||||
};
|
||||
} catch (pollError: any) {
|
||||
// Check if polling error is a subscription error (HTTP 429/402)
|
||||
if (pollError?.response?.status === 429 || pollError?.response?.status === 402) {
|
||||
const errorData = pollError.response?.data || {};
|
||||
const errorMessage = errorData.message || errorData.error || 'Subscription limit exceeded';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Medium generation failed: ${errorMessage}`,
|
||||
error: errorMessage,
|
||||
error_type: 'subscription_limit',
|
||||
provider: errorData.provider || 'unknown',
|
||||
suggestion: 'Please renew your subscription to continue generating content.',
|
||||
action_taken: 'outline_confirmed_medium_generation_failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Other polling errors - still return success since task was started
|
||||
// The polling will handle the error in the background
|
||||
console.warn('Initial poll check failed, but task was started:', pollError);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
|
||||
task_id,
|
||||
action_taken: 'outline_confirmed_medium_generation_started'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed! Ready to generate content for ${outline.length} sections.`,
|
||||
next_step_suggestion: 'Now you can choose to generate content for individual sections or all sections at once using the available suggestions.',
|
||||
outline_sections: outline.length,
|
||||
action_taken: 'outline_confirmed_only'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline confirmation error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Outline confirmation failed: ${errorMessage}`,
|
||||
suggestion: 'Please try again or contact support if the problem persists.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Chat with Outline action
|
||||
useCopilotActionTyped({
|
||||
name: 'chatWithOutline',
|
||||
description: 'Chat with the outline to get insights, summaries, and interesting questions about the content structure',
|
||||
parameters: [
|
||||
{ name: 'question', type: 'string', description: 'Question about the outline or content structure', required: false }
|
||||
],
|
||||
handler: async ({ question }: { question?: string }) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No outline available to chat with.',
|
||||
suggestion: 'Please generate an outline first before chatting about it.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available for outline discussion.',
|
||||
suggestion: 'Please complete research first before chatting about the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Provide comprehensive outline and research context
|
||||
const outlineContext = {
|
||||
totalSections: outline.length,
|
||||
sections: outline.map(section => ({
|
||||
heading: section.heading,
|
||||
subheadings: section.subheadings,
|
||||
keyPoints: section.key_points,
|
||||
targetWords: section.target_words
|
||||
})),
|
||||
researchSummary: {
|
||||
sources: research.sources?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational',
|
||||
contentAngles: research.suggested_angles || []
|
||||
},
|
||||
totalTargetWords: outline.reduce((sum, section) => sum + (section.target_words || 0), 0)
|
||||
};
|
||||
|
||||
// If no specific question, provide a summary and interesting questions
|
||||
if (!question) {
|
||||
const summary = `I can see you have a well-structured outline with ${outlineContext.totalSections} sections targeting ${outlineContext.totalTargetWords} words total. The outline covers: ${outline.map(s => s.heading).join(', ')}.`;
|
||||
|
||||
const interestingQuestions = [
|
||||
"What's the main narrative flow of this outline?",
|
||||
"How does each section build upon the previous one?",
|
||||
"What are the key takeaways readers will get from each section?",
|
||||
"How well does this outline address the search intent: " + outlineContext.researchSummary.searchIntent + "?",
|
||||
"What additional sections might strengthen this content?",
|
||||
"How can we improve the engagement factor of each section?"
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${summary}\n\nHere are some interesting questions to explore:\n${interestingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}`,
|
||||
outlineContext: outlineContext,
|
||||
next_step_suggestion: 'Ask me any specific questions about the outline structure, content flow, or how to improve it.'
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific questions about the outline
|
||||
return {
|
||||
success: true,
|
||||
message: `Great question about the outline! Based on the current structure and research data, I can help you analyze and improve the outline.`,
|
||||
outlineContext: outlineContext,
|
||||
question: question,
|
||||
next_step_suggestion: 'Feel free to ask more specific questions about sections, flow, or content strategy.'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Chat with outline error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to chat with outline: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ask a more specific question about the outline.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Flow Analysis Actions
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQuality',
|
||||
description: 'Analyze the flow and quality of blog content to get improvement suggestions (basic analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running quality analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call basic flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowBasic({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Content quality analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
suggestions: s.suggestions
|
||||
})),
|
||||
overall_suggestions: analysis.overall_suggestions
|
||||
},
|
||||
next_step_suggestion: 'Use "🔍 Deep Content Analysis" for detailed, section-by-section analysis with more specific recommendations.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Content quality analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Content quality analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to analyze content quality: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQualityAdvanced',
|
||||
description: 'Get detailed, section-by-section analysis of content quality and flow (advanced analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for advanced analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running advanced analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for advanced analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call advanced flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowAdvanced({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Advanced content analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
detailed_analysis: s.detailed_analysis,
|
||||
suggestions: s.suggestions
|
||||
}))
|
||||
},
|
||||
next_step_suggestion: 'Review the detailed analysis and implement the suggested improvements to enhance your content quality.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Advanced content analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Advanced content analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to perform advanced content analysis: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default OutlineFeedbackForm;
|
||||
177
frontend/src/components/BlogWriter/OutlineGenerator.tsx
Normal file
177
frontend/src/components/BlogWriter/OutlineGenerator.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../services/blogWriterCache';
|
||||
|
||||
interface OutlineGeneratorProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
onModalShow?: () => void; // Callback to show progress modal immediately
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow,
|
||||
navigateToPhase,
|
||||
onOutlineCreated
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
generateNow: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
|
||||
// Return cached result - caller should handle setting outline state
|
||||
return {
|
||||
success: true,
|
||||
cached: true,
|
||||
outline: cachedOutline.outline,
|
||||
title_options: cachedOutline.title_options
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
onModalShow?.();
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
return { success: true, task_id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
}));
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
|
||||
|
||||
// Navigate to outline phase when cached outline is found
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Update parent state with cached outline (same as header button does)
|
||||
if (onOutlineCreated) {
|
||||
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
|
||||
cached: true,
|
||||
outline: cachedOutline.outline,
|
||||
title_options: cachedOutline.title_options
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
onModalShow?.();
|
||||
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
|
||||
// Start polling immediately after getting task_id
|
||||
// This ensures we catch progress messages from the very beginning
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🧩 Outline generation started! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Provide more specific error messages based on the error type
|
||||
let userMessage = '❌ Outline generation failed. ';
|
||||
if (errorMessage.includes('503') || errorMessage.includes('overloaded')) {
|
||||
userMessage += 'The AI service is temporarily overloaded. Please try again in a few minutes.';
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage += 'The request timed out. Please try again.';
|
||||
} else if (errorMessage.includes('Invalid outline structure')) {
|
||||
userMessage += 'The AI generated an invalid response. Please try again with different research data.';
|
||||
} else {
|
||||
userMessage += `${errorMessage}. Please try again or contact support if the problem persists.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: userMessage
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #388e3c',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#388e3c' }}>🧩 Generating Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing research results and content angles...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Structuring content based on keyword analysis...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Creating logical flow and section hierarchy...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for SEO and reader engagement...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
});
|
||||
|
||||
export default OutlineGenerator;
|
||||
561
frontend/src/components/BlogWriter/OutlineIntelligenceChips.tsx
Normal file
561
frontend/src/components/BlogWriter/OutlineIntelligenceChips.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineIntelligenceChipsProps {
|
||||
sections: BlogOutlineSection[];
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
}
|
||||
|
||||
const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
|
||||
sections,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
const chips = [
|
||||
{
|
||||
id: 'research',
|
||||
label: 'Research Data',
|
||||
icon: '📊',
|
||||
color: '#e3f2fd',
|
||||
textColor: '#1976d2',
|
||||
data: researchCoverage,
|
||||
description: 'How well your research data is being utilized',
|
||||
metrics: researchCoverage ? [
|
||||
{ label: 'Sources Used', value: researchCoverage.sources_utilized, color: '#1976d2' },
|
||||
{ label: 'Content Gaps', value: researchCoverage.content_gaps_identified, color: '#ff9800' },
|
||||
{ label: 'Advantages', value: researchCoverage.competitive_advantages.length, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'mapping',
|
||||
label: 'Source Mapping',
|
||||
icon: '🔗',
|
||||
color: '#f3e5f5',
|
||||
textColor: '#7b1fa2',
|
||||
data: sourceMappingStats,
|
||||
description: 'Intelligence in mapping sources to sections',
|
||||
metrics: sourceMappingStats ? [
|
||||
{ label: 'Mapped', value: sourceMappingStats.total_sources_mapped, color: '#7b1fa2' },
|
||||
{ label: 'Coverage', value: `${Math.round(sourceMappingStats.coverage_percentage)}%`, color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) },
|
||||
{ label: 'Relevance', value: `${Math.round(sourceMappingStats.average_relevance_score * 100)}%`, color: getConfidenceColor(sourceMappingStats.average_relevance_score) },
|
||||
{ label: 'High Conf', value: sourceMappingStats.high_confidence_mappings, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'grounding',
|
||||
label: 'Grounding Insights',
|
||||
icon: '🧠',
|
||||
color: '#e8f5e8',
|
||||
textColor: '#2e7d32',
|
||||
data: groundingInsights,
|
||||
description: 'AI-powered insights from search grounding',
|
||||
metrics: groundingInsights ? [
|
||||
{
|
||||
label: 'Confidence',
|
||||
value: groundingInsights.confidence_analysis ? `${Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%` : 'N/A',
|
||||
color: groundingInsights.confidence_analysis ? getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Authority',
|
||||
value: groundingInsights.authority_analysis ? `${Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.authority_analysis ? getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Coverage',
|
||||
value: groundingInsights.content_relationships ? `${Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.content_relationships ? getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) : '#666'
|
||||
}
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
label: 'Optimization',
|
||||
icon: '🎯',
|
||||
color: '#fff3e0',
|
||||
textColor: '#f57c00',
|
||||
data: optimizationResults,
|
||||
description: 'AI optimization and quality assessment',
|
||||
metrics: optimizationResults ? [
|
||||
{
|
||||
label: 'Quality',
|
||||
value: `${optimizationResults.overall_quality_score}/10`,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Grade',
|
||||
value: getQualityGrade(optimizationResults.overall_quality_score).grade,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Focus',
|
||||
value: optimizationResults.optimization_focus,
|
||||
color: '#f57c00'
|
||||
},
|
||||
{
|
||||
label: 'Improvements',
|
||||
value: optimizationResults.improvements_made.length,
|
||||
color: '#4caf50'
|
||||
}
|
||||
] : []
|
||||
}
|
||||
];
|
||||
|
||||
const renderModal = (chipId: string) => {
|
||||
const chip = chips.find(c => c.id === chipId);
|
||||
if (!chip || !chip.data) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '28px' }}>{chip.icon}</span>
|
||||
{chip.label}
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
{chip.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveModal(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{ color: '#333' }}>
|
||||
{chipId === 'research' && researchCoverage && (
|
||||
<div>
|
||||
{/* Key Metrics */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Research Utilization Metrics</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1976d2', marginBottom: '8px' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Utilized</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources actively used in outline generation
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ff9800', marginBottom: '8px' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Content Gaps Identified</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Missing topics that could strengthen your content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Competitive Advantages</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Unique angles identified from research
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Competitive Advantages */}
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Key Competitive Advantages</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #c8e6c9'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'mapping' && sourceMappingStats && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Source Mapping Intelligence</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#7b1fa2', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Mapped</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources intelligently linked to sections
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.coverage_percentage)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Percentage of sections with mapped sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.average_relevance_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Relevance</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well sources match section content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Mappings with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'grounding' && groundingInsights && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Grounding Metadata Insights</h3>
|
||||
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Confidence Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average confidence score across all sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence Sources</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Sources with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Authority Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Authority</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average authority score of sources
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Top Authority Sources:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 5).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #bbdefb'
|
||||
}}>
|
||||
{source.title.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Content Relationships</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Concept Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well concepts are covered across sections
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Related Concepts:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 8).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ffcc02'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Search Intent Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Primary Intent</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Main user intent identified from search data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>User Questions:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 5).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ce93d8'
|
||||
}}>
|
||||
{question.substring(0, 50)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'optimization' && optimizationResults && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Optimization Results</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Overall Quality</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
AI-assessed quality score of the outline
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Quality Grade</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Letter grade based on quality assessment
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#f57c00', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Focus Area</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Primary area of optimization focus
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{optimizationResults.improvements_made.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Improvements Made</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Number of optimizations applied
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Improvements Made:</h4>
|
||||
<div style={{ backgroundColor: '#f8f9fa', borderRadius: '12px', padding: '16px', border: '1px solid #e5e7eb' }}>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#374151', marginBottom: '8px', lineHeight: '1.5' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const availableChips = chips.filter(chip => chip.data);
|
||||
|
||||
if (availableChips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{availableChips.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
onClick={() => setActiveModal(chip.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: chip.color,
|
||||
color: chip.textColor,
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
minWidth: '140px',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{chip.icon}</span>
|
||||
<span>{chip.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeModal && renderModal(activeModal)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineIntelligenceChips;
|
||||
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal file
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OutlineProgressModalProps {
|
||||
isVisible: boolean;
|
||||
status: string;
|
||||
progressMessages: string[];
|
||||
latestMessage: string;
|
||||
error: string | null;
|
||||
titleOverride?: string;
|
||||
}
|
||||
|
||||
export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
isVisible,
|
||||
status,
|
||||
progressMessages,
|
||||
latestMessage,
|
||||
error,
|
||||
titleOverride
|
||||
}) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getUserFriendlyMessage = (message: string): string => {
|
||||
// Map technical backend messages to user-friendly ones
|
||||
if (message.includes('Starting outline generation')) {
|
||||
return '🧩 Starting to create your blog outline...';
|
||||
}
|
||||
if (message.includes('Analyzing research data and building content strategy')) {
|
||||
return '📊 Analyzing your research data to build the perfect content strategy...';
|
||||
}
|
||||
if (message.includes('Generating AI-powered outline with research insights')) {
|
||||
return '🤖 Creating an intelligent outline using AI and your research insights...';
|
||||
}
|
||||
if (message.includes('Making AI request to generate structured outline')) {
|
||||
return '🔄 Generating your structured blog outline...';
|
||||
}
|
||||
if (message.includes('Calling Gemini API for outline generation')) {
|
||||
return '🤖 AI is crafting your personalized blog structure...';
|
||||
}
|
||||
if (message.includes('Processing outline structure and validating sections')) {
|
||||
return '📝 Processing and validating your outline sections...';
|
||||
}
|
||||
if (message.includes('Running parallel processing for maximum speed')) {
|
||||
return '⚡ Optimizing processing speed for faster results...';
|
||||
}
|
||||
if (message.includes('Applying intelligent source-to-section mapping')) {
|
||||
return '🔗 Intelligently matching your research sources to outline sections...';
|
||||
}
|
||||
if (message.includes('Extracting grounding metadata insights')) {
|
||||
return '🧠 Extracting valuable insights from your research data...';
|
||||
}
|
||||
if (message.includes('Enhancing sections with grounding insights')) {
|
||||
return '✨ Enhancing your outline sections with research-backed insights...';
|
||||
}
|
||||
if (message.includes('Optimizing outline for better flow and engagement')) {
|
||||
return '🎯 Optimizing your outline for maximum reader engagement...';
|
||||
}
|
||||
if (message.includes('Rebalancing word count distribution')) {
|
||||
return '⚖️ Balancing content distribution across sections...';
|
||||
}
|
||||
if (message.includes('Outline generation and optimization completed successfully')) {
|
||||
return '✅ Your blog outline has been successfully created and optimized!';
|
||||
}
|
||||
if (message.includes('Outline generated successfully')) {
|
||||
return '🎉 Success! Your personalized blog outline is ready!';
|
||||
}
|
||||
|
||||
// Return the original message if no mapping found
|
||||
return message;
|
||||
};
|
||||
|
||||
const getProgressPercentage = (): number => {
|
||||
if (status === 'complete') return 100;
|
||||
if (status === 'error') return 0;
|
||||
|
||||
// Estimate progress based on common message patterns
|
||||
const messageCount = progressMessages.length;
|
||||
if (messageCount === 0) return 0;
|
||||
if (messageCount >= 10) return 90;
|
||||
return Math.min(messageCount * 10, 90);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '0',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header with background image */}
|
||||
<div style={{
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
padding: '32px',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Dark overlay */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: '16px 16px 0 0'
|
||||
}} />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
||||
</h2>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '12px',
|
||||
height: '8px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: status === 'error' ? '#ef4444' : '#10b981',
|
||||
height: '100%',
|
||||
width: `${getProgressPercentage()}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
borderRadius: '12px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
opacity: 0.9,
|
||||
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{titleOverride
|
||||
? (status === 'complete'
|
||||
? 'Your AI-generated blog content is ready!'
|
||||
: status === 'error'
|
||||
? 'Something went wrong during generation'
|
||||
: 'AI is generating your blog content...')
|
||||
: (status === 'complete'
|
||||
? 'Your AI-powered blog outline is ready to use!'
|
||||
: status === 'error'
|
||||
? 'Something went wrong during outline generation'
|
||||
: 'AI is analyzing your research and creating the perfect blog structure...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '24px' }}>
|
||||
{error ? (
|
||||
<div style={{
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Status */}
|
||||
<div style={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
|
||||
animation: status === 'executing' ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
Current Status
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
color: '#1e40af',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Messages */}
|
||||
{progressMessages.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
Progress Timeline
|
||||
</h4>
|
||||
<div style={{
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{progressMessages.slice().reverse().slice(0, 8).map((message, index) => (
|
||||
<div key={index} style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginBottom: index < Math.min(progressMessages.length - 1, 7) ? '8px' : '0',
|
||||
paddingLeft: '20px',
|
||||
position: 'relative',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '2px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: index === 0 ? '#10b981' : '#d1d5db'
|
||||
}} />
|
||||
{getUserFriendlyMessage(message)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS for pulse animation */}
|
||||
<style>
|
||||
{`
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
35
frontend/src/components/BlogWriter/OutlineRefiner.tsx
Normal file
35
frontend/src/components/BlogWriter/OutlineRefiner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineRefinerProps {
|
||||
outline: BlogOutlineSection[];
|
||||
onOutlineUpdated: (outline: BlogOutlineSection[]) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineRefiner: React.FC<OutlineRefinerProps> = ({
|
||||
outline,
|
||||
onOutlineUpdated
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline (add/remove/move/merge)',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'add|remove|move|merge|rename', required: true },
|
||||
{ name: 'sectionId', type: 'string', description: 'Target section ID', required: false },
|
||||
{ name: 'payload', type: 'string', description: 'JSON payload for operation', required: false },
|
||||
],
|
||||
handler: async ({ operation, sectionId, payload }: { operation: string; sectionId?: string; payload?: string }) => {
|
||||
const payloadObj = payload ? (() => { try { return JSON.parse(payload); } catch { return {}; } })() : undefined;
|
||||
const res = await blogWriterApi.refineOutline({ outline, operation, section_id: sectionId, payload: payloadObj });
|
||||
if (res?.outline) onOutlineUpdated(res.outline);
|
||||
return { success: true };
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default OutlineRefiner;
|
||||
428
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
428
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface Phase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
current: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface PhaseNavigationProps {
|
||||
phases: Phase[];
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
currentPhase: string;
|
||||
copilotKitAvailable?: boolean;
|
||||
actionHandlers?: PhaseActionHandlers;
|
||||
// State for determining which actions to show
|
||||
hasResearch?: boolean;
|
||||
hasOutline?: boolean;
|
||||
outlineConfirmed?: boolean;
|
||||
hasContent?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
hasSEOAnalysis?: boolean;
|
||||
seoRecommendationsApplied?: boolean;
|
||||
hasSEOMetadata?: boolean;
|
||||
}
|
||||
|
||||
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
phases,
|
||||
onPhaseClick,
|
||||
currentPhase,
|
||||
copilotKitAvailable = true,
|
||||
actionHandlers,
|
||||
hasResearch = false,
|
||||
hasOutline = false,
|
||||
outlineConfirmed = false,
|
||||
hasContent = false,
|
||||
contentConfirmed = false,
|
||||
hasSEOAnalysis = false,
|
||||
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 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':
|
||||
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 };
|
||||
}
|
||||
break;
|
||||
case 'content':
|
||||
if (hasOutline && !outlineConfirmed) {
|
||||
return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null };
|
||||
}
|
||||
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
|
||||
}
|
||||
break;
|
||||
}
|
||||
return { label: '', handler: null };
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="phase-nav-container">
|
||||
{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 if no research exists
|
||||
// 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' && !hasResearch;
|
||||
// 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 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)
|
||||
const showAction = action.handler && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
|
||||
);
|
||||
|
||||
// Determine chip class
|
||||
const chipClass = [
|
||||
'phase-chip',
|
||||
isCurrent ? 'current' : '',
|
||||
isCompleted && !isCurrent ? 'completed' : '',
|
||||
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
|
||||
isDisabled ? 'disabled' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
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}
|
||||
>
|
||||
<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}`}
|
||||
>
|
||||
<span className="phase-action-icon">▶</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigation;
|
||||
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { Phase } from './PhaseNavigation';
|
||||
|
||||
// Test component to verify phase navigation functionality
|
||||
export const PhaseNavigationTest: React.FC = () => {
|
||||
const [currentPhase, setCurrentPhase] = useState<string>('research');
|
||||
|
||||
const testPhases: Phase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research',
|
||||
icon: '🔍',
|
||||
description: 'Research your topic and gather data',
|
||||
completed: true,
|
||||
current: currentPhase === 'research',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Outline',
|
||||
icon: '📝',
|
||||
description: 'Create and refine your blog outline',
|
||||
completed: true,
|
||||
current: currentPhase === 'outline',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content',
|
||||
icon: '✍️',
|
||||
description: 'Generate and edit your blog content',
|
||||
completed: false,
|
||||
current: currentPhase === 'content',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO',
|
||||
icon: '📈',
|
||||
description: 'Optimize for search engines',
|
||||
completed: false,
|
||||
current: currentPhase === 'seo',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish',
|
||||
icon: '🚀',
|
||||
description: 'Publish your blog post',
|
||||
completed: false,
|
||||
current: currentPhase === 'publish',
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
|
||||
const handlePhaseClick = (phaseId: string) => {
|
||||
setCurrentPhase(phaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h2>Phase Navigation Test</h2>
|
||||
<p>Current Phase: <strong>{currentPhase}</strong></p>
|
||||
|
||||
<PhaseNavigation
|
||||
phases={testPhases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h3>Phase Status:</h3>
|
||||
<ul>
|
||||
{testPhases.map(phase => (
|
||||
<li key={phase.id}>
|
||||
<strong>{phase.name}</strong>:
|
||||
{phase.completed ? ' ✅ Completed' : ' ⏳ Pending'} |
|
||||
{phase.current ? ' 🎯 Current' : ''} |
|
||||
{phase.disabled ? ' 🚫 Disabled' : ' ✅ Enabled'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigationTest;
|
||||
379
frontend/src/components/BlogWriter/Publisher.tsx
Normal file
379
frontend/src/components/BlogWriter/Publisher.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../api/wordpress';
|
||||
import { validateAndRefreshWixTokens } from '../../utils/wixTokenUtils';
|
||||
import WixConnectModal from './BlogWriterUtils/WixConnectModal';
|
||||
|
||||
interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
convertMarkdownToHTML: (md: string) => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface WixConnectionStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
permissions?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Publisher: React.FC<PublisherProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata
|
||||
}) => {
|
||||
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
|
||||
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
|
||||
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
|
||||
const [checkingWordPressStatus, setCheckingWordPressStatus] = useState(false);
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const [pendingWixPublish, setPendingWixPublish] = useState<(() => Promise<any>) | null>(null);
|
||||
|
||||
// Check platform connection statuses on component mount
|
||||
useEffect(() => {
|
||||
checkWixConnectionStatus();
|
||||
checkWordPressConnectionStatus();
|
||||
}, []);
|
||||
|
||||
const checkWixConnectionStatus = async () => {
|
||||
setCheckingWixStatus(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/wix/connection/status');
|
||||
setWixConnectionStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check Wix connection status:', error);
|
||||
setWixConnectionStatus({
|
||||
connected: false,
|
||||
has_permissions: false,
|
||||
error: 'Failed to check connection status'
|
||||
});
|
||||
} finally {
|
||||
setCheckingWixStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkWordPressConnectionStatus = async () => {
|
||||
setCheckingWordPressStatus(true);
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch (error: any) {
|
||||
// getStatus now handles 404 gracefully, so we should rarely hit this
|
||||
// Only log non-404 errors
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
}
|
||||
setWordpressSites([]);
|
||||
} finally {
|
||||
setCheckingWordPressStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to publish to Wix
|
||||
const publishToWix = async (md: string, metadata: BlogSEOMetadataResponse | null, accessToken?: string): Promise<any> => {
|
||||
// Get access token if not provided
|
||||
if (!accessToken) {
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (!tokenResult.accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens not available. Please connect your Wix account.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
accessToken = tokenResult.accessToken;
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = metadata?.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract cover image URL, skip if base64 (Wix needs HTTP URL)
|
||||
let coverImageUrl: string | undefined = undefined;
|
||||
if (metadata?.open_graph?.image) {
|
||||
const imageUrl = metadata.open_graph.image;
|
||||
// Skip base64 images - Wix import_image needs HTTP/HTTPS URL
|
||||
if (typeof imageUrl === 'string' && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
|
||||
coverImageUrl = imageUrl;
|
||||
} else {
|
||||
console.warn('Skipping cover image - Wix requires HTTP/HTTPS URL, received:', imageUrl?.substring(0, 50));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Publish using same endpoint as WixTestPage
|
||||
// Backend will lookup/create category and tag IDs from names if needed
|
||||
const response = await apiClient.post('/api/wix/test/publish/real', {
|
||||
title: title,
|
||||
content: md, // Use markdown, backend converts it
|
||||
cover_image_url: coverImageUrl,
|
||||
// Pass category/tag names - backend will lookup existing or create new ones
|
||||
category_names: metadata?.blog_categories || [],
|
||||
tag_names: metadata?.blog_tags || [],
|
||||
publish: true,
|
||||
access_token: accessToken,
|
||||
member_id: undefined, // Let backend derive from token
|
||||
seo_metadata: metadata ? {
|
||||
seo_title: metadata.seo_title,
|
||||
meta_description: metadata.meta_description,
|
||||
focus_keyword: metadata.focus_keyword,
|
||||
blog_tags: metadata.blog_tags || [], // Used for SEO keywords
|
||||
social_hashtags: metadata.social_hashtags || [],
|
||||
open_graph: metadata.open_graph || {},
|
||||
twitter_card: metadata.twitter_card || {},
|
||||
canonical_url: metadata.canonical_url
|
||||
} : undefined
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: response.data.url,
|
||||
post_id: response.data.post_id,
|
||||
message: 'Blog post published successfully to Wix!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If auth error, token may be invalid - try refreshing or reconnect
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// Try to refresh one more time
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (tokenResult.needsReconnect) {
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, metadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens expired. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix'
|
||||
};
|
||||
}
|
||||
// If refresh worked, retry once
|
||||
if (tokenResult.accessToken) {
|
||||
return await publishToWix(md, metadata, tokenResult.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Wix connection success - retry publish
|
||||
const handleWixConnectionSuccess = async () => {
|
||||
if (pendingWixPublish) {
|
||||
const publishFn = pendingWixPublish;
|
||||
setPendingWixPublish(null);
|
||||
// Small delay to ensure tokens are saved in sessionStorage
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Retry the publish - this will be executed and return result
|
||||
// Note: The result won't show in CopilotKit UI since we're outside the action handler
|
||||
// But the publish will succeed and user will see their blog on Wix
|
||||
const result = await publishFn();
|
||||
console.log('Wix publish after connection:', result);
|
||||
// Optionally show a success notification
|
||||
if (result.success) {
|
||||
// Publish succeeded - user's blog is now on Wix
|
||||
console.log('Blog published to Wix successfully after connection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrying publish after connection:', error);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
// Enhanced publish action with Wix support
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
parameters: [
|
||||
{ name: 'platform', type: 'string', description: 'wix|wordpress', required: true },
|
||||
{ name: 'schedule_time', type: 'string', description: 'Optional ISO datetime', required: false }
|
||||
],
|
||||
handler: async ({ platform, schedule_time }: { platform: 'wix' | 'wordpress'; schedule_time?: string }) => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = convertMarkdownToHTML(md);
|
||||
|
||||
if (platform === 'wix') {
|
||||
// Proactively validate and refresh tokens
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
|
||||
if (tokenResult.needsReconnect || !tokenResult.accessToken) {
|
||||
// Store the publish function to retry after connection
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, seoMetadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix account not connected. Please connect your Wix account to publish.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
|
||||
// We have a valid access token, proceed with publishing
|
||||
return await publishToWix(md, seoMetadata, tokenResult.accessToken);
|
||||
} else if (platform === 'wordpress') {
|
||||
// WordPress publishing
|
||||
if (!seoMetadata) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Generate SEO metadata first. Use the "Next: Generate SEO Metadata" suggestion to create metadata before publishing.'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has connected WordPress sites
|
||||
if (wordpressSites.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No WordPress sites connected. Please connect a WordPress site first. Go to Settings > Integrations to add your WordPress site.',
|
||||
action_required: 'connect_wordpress'
|
||||
};
|
||||
}
|
||||
|
||||
// Find first active site, or use first site if none are active
|
||||
const activeSite = wordpressSites.find(site => site.is_active) || wordpressSites[0];
|
||||
if (!activeSite) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No active WordPress sites found. Please activate a WordPress site connection.',
|
||||
action_required: 'activate_wordpress'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = seoMetadata.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract excerpt from SEO metadata
|
||||
const excerpt = seoMetadata.meta_description || '';
|
||||
|
||||
// Build WordPress publish request
|
||||
const publishRequest: WordPressPublishRequest = {
|
||||
site_id: activeSite.id,
|
||||
title: title,
|
||||
content: html,
|
||||
excerpt: excerpt,
|
||||
status: 'publish',
|
||||
meta_description: seoMetadata.meta_description || excerpt,
|
||||
tags: seoMetadata.blog_tags || [],
|
||||
categories: seoMetadata.blog_categories || []
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await wordpressAPI.publishContent(publishRequest);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,
|
||||
post_id: result.post_id,
|
||||
message: `Blog post published successfully to WordPress site "${activeSite.site_name}"!`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: result.error || 'Failed to publish to WordPress'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to WordPress: ${error.response?.data?.detail || error.message || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Unsupported platform: ${platform}. Supported platforms are 'wix' and 'wordpress'.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status, result }: any) => {
|
||||
if (status === 'complete') {
|
||||
if (result?.success) {
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ color: 'green', fontWeight: 'bold' }}>
|
||||
✅ Published Successfully!
|
||||
</div>
|
||||
{result.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href={result.url} target="_blank" rel="noopener noreferrer">
|
||||
View Published Post
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{result.post_id && (
|
||||
<div style={{ fontSize: '0.9em', color: '#666', marginTop: 4 }}>
|
||||
Post ID: {result.post_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ color: 'red', fontWeight: 'bold' }}>
|
||||
❌ Publishing Failed
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#666' }}>
|
||||
{result?.message}
|
||||
</div>
|
||||
{result?.action_required === 'connect_wix' && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href="/wix-test" target="_blank" rel="noopener noreferrer">
|
||||
Connect Wix Account
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{(result?.action_required === 'connect_wordpress' || result?.action_required === 'activate_wordpress') && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href="/settings/integrations" target="_blank" rel="noopener noreferrer">
|
||||
Manage WordPress Connections
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WixConnectModal
|
||||
isOpen={showWixConnectModal}
|
||||
onClose={() => {
|
||||
setShowWixConnectModal(false);
|
||||
setPendingWixPublish(null);
|
||||
}}
|
||||
onConnectionSuccess={handleWixConnectionSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Publisher;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const RegisterBlogWriterActions: React.FC = () => {
|
||||
useCopilotActionTyped({
|
||||
name: 'Generate All Sections of Outline',
|
||||
description: 'Generate content for every section in the current outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Frontend-only placeholder; generation handled via individual actions in UI for now
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterBlogWriterActions;
|
||||
|
||||
|
||||
303
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
303
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
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 = useResearchPolling({
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Set of statuses that indicate successful completion
|
||||
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 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 shouldClose = showProgressModal && (
|
||||
isCompleted ||
|
||||
(hasResult && normalizedStatus !== 'failed') ||
|
||||
(!polling.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
|
||||
]);
|
||||
|
||||
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');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
};
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
const _ = forceUpdate;
|
||||
|
||||
// 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 = polling.currentStatus !== 'completed' &&
|
||||
polling.currentStatus !== 'in_progress' &&
|
||||
polling.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 (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
|
||||
const latestMessage = polling.progressMessages[polling.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 (polling.currentStatus === 'in_progress' || polling.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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
|
||||
<input
|
||||
type="text"
|
||||
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' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
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' }}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000 words (Comprehensive blog)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}
|
||||
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' }}
|
||||
>
|
||||
🚀 Start Research
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Additional action to catch the specific suggestion message
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
parameters: [
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: false },
|
||||
{ name: 'industry', type: 'string', description: 'Industry', required: false },
|
||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
|
||||
],
|
||||
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);
|
||||
return "Starting research with your provided keywords.";
|
||||
} catch (error) {
|
||||
console.error('Failed to start research:', error);
|
||||
return "Failed to start research. Please try again.";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchAction;
|
||||
@@ -0,0 +1,238 @@
|
||||
import React from 'react';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface GoogleSearchModalProps {
|
||||
research: BlogResearchResponse;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GoogleSearchModal: React.FC<GoogleSearchModalProps> = ({ research, onClose }) => {
|
||||
if (!research.search_widget && !research.search_queries?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSearchClick = (query: string) => {
|
||||
// Open Google Search in new tab per Google requirements
|
||||
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
onClick={onClose}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
borderBottom: '2px solid #f3f4f6',
|
||||
paddingBottom: '16px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="#4285F4" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
color: '#1f2937',
|
||||
fontSize: '24px',
|
||||
fontWeight: '700'
|
||||
}}>
|
||||
Google Search Suggestions
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
Explore related searches and sources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
width: '40px',
|
||||
height: '40px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Google Search Widget - Display exactly as provided per Google requirements */}
|
||||
{research.search_widget && (
|
||||
<div style={{
|
||||
marginBottom: '32px',
|
||||
width: '100%',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<span>🔍</span>
|
||||
<span>Search Suggestions (Click to open in Google)</span>
|
||||
</div>
|
||||
{/* Render Google's HTML exactly as provided - no modifications */}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: research.search_widget }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Queries List */}
|
||||
{research.search_queries && research.search_queries.length > 0 && (
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<h4 style={{
|
||||
margin: '0 0 16px 0',
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span>📋</span>
|
||||
Additional Search Queries
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{research.search_queries.map((query, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSearchClick(query)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '14px',
|
||||
color: '#374151',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#4285F4';
|
||||
e.currentTarget.style.transform = 'translateX(4px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{query}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#4285F4" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Footer */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#eff6ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #bfdbfe'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
fontSize: '13px',
|
||||
color: '#1e40af'
|
||||
}}>
|
||||
<span style={{ fontSize: '16px', lineHeight: '1.5' }}>ℹ️</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>
|
||||
About These Suggestions
|
||||
</div>
|
||||
<div style={{ lineHeight: '1.6' }}>
|
||||
These search suggestions are generated by Google's AI to help you explore related topics.
|
||||
Clicking any suggestion will open Google Search in a new tab to find the latest and most relevant information.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleSearchModal;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface ResearchGroundingProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
export const ResearchGrounding: React.FC<ResearchGroundingProps> = ({ research }) => {
|
||||
const renderConfidenceScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.5;
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '50px',
|
||||
height: '6px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
backgroundColor: color,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!research.grounding_metadata) {
|
||||
return (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: '#666' }}>
|
||||
No grounding metadata available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { grounding_chunks, grounding_supports, citations, web_search_queries } = research.grounding_metadata;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔗 Google Grounding Metadata</h3>
|
||||
|
||||
{/* Grounding Chunks */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
📚 Grounding Chunks ({grounding_chunks.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{grounding_chunks.map((chunk, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<h5 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333' }}>
|
||||
{chunk.title}
|
||||
</h5>
|
||||
{chunk.confidence_score && renderConfidenceScore(chunk.confidence_score)}
|
||||
</div>
|
||||
<a
|
||||
href={chunk.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}
|
||||
>
|
||||
{chunk.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grounding Supports */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
🎯 Grounding Supports ({grounding_supports.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{grounding_supports.map((support, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
Chunks: {support.grounding_chunk_indices.join(', ')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{support.confidence_scores.map((score, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: '10px', color: '#666' }}>C{i+1}:</span>
|
||||
{renderConfidenceScore(score)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#555',
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
"{support.segment_text}"
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Citations */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#555', fontSize: '16px' }}>
|
||||
📝 Inline Citations ({citations.length})
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{citations.map((citation, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{citation.citation_type}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
{citation.reference}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{citation.source_indices.map((sourceIdx, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
S{sourceIdx + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#555',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{citation.text}"
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#999',
|
||||
marginTop: '4px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
Position: {citation.start_index}-{citation.end_index}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchGrounding;
|
||||
@@ -0,0 +1,540 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface ResearchSourcesProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
interface KeywordChipGroupProps {
|
||||
title: string;
|
||||
keywords: string[];
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
icon: string;
|
||||
showCount: number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const KeywordChipGroup: React.FC<KeywordChipGroupProps> = ({
|
||||
title,
|
||||
keywords,
|
||||
color,
|
||||
backgroundColor,
|
||||
icon,
|
||||
showCount,
|
||||
tooltip
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const visibleKeywords = isExpanded ? keywords : keywords.slice(0, showCount);
|
||||
const hasMore = keywords.length > showCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: hasMore ? 'pointer' : 'default',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasMore) {
|
||||
setIsExpanded(true);
|
||||
e.currentTarget.style.boxShadow = '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)';
|
||||
e.currentTarget.style.borderColor = color;
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (hasMore) {
|
||||
setIsExpanded(false);
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px', paddingRight: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>{icon}</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
letterSpacing: '0.025em',
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#6b7280',
|
||||
backgroundColor: '#f3f4f6',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #e5e7eb',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{keywords.length}
|
||||
</span>
|
||||
{/* Help Icon */}
|
||||
<span
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '20px',
|
||||
minHeight: '20px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
❓
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{visibleKeywords.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: backgroundColor,
|
||||
color: '#374151',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
border: `1px solid ${color}40`,
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
{hasMore && !isExpanded && (
|
||||
<span style={{
|
||||
backgroundColor: '#f9fafb',
|
||||
color: '#6b7280',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #d1d5db',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
+{keywords.length - showCount} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Professional Tooltip - Only show when clicked */}
|
||||
{showTooltip && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1f2937',
|
||||
color: '#f9fafb',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
maxWidth: '280px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
border: '1px solid #374151'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', color: '#f3f4f6' }}>
|
||||
{title} Keywords
|
||||
</div>
|
||||
<div style={{ color: '#d1d5db' }}>
|
||||
{tooltip}
|
||||
</div>
|
||||
{/* Tooltip arrow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #1f2937'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResearchSources: React.FC<ResearchSourcesProps> = ({ research }) => {
|
||||
|
||||
const renderCredibilityScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.8; // Default to 0.8 if undefined
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
const radius = 20;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDasharray = circumference;
|
||||
const strokeDashoffset = circumference - (safeScore * circumference);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ position: 'relative', width: '44px', height: '44px' }}>
|
||||
<svg width="44" height="44" style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600',
|
||||
color: color
|
||||
}}>
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '16px', padding: '16px', width: '100%', overflow: 'hidden' }}>
|
||||
{/* Keywords Sidebar - Moved to Left */}
|
||||
<div style={{ flex: 1, minWidth: '300px', maxWidth: '400px', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff',
|
||||
position: 'sticky',
|
||||
top: '16px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
borderLeft: '4px solid #3b82f6'
|
||||
}}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '-0.025em'
|
||||
}}>
|
||||
🎯 Keywords
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Progressive Disclosure Keyword Chips */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Primary Keywords */}
|
||||
{research.keyword_analysis?.primary && research.keyword_analysis.primary.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Primary"
|
||||
keywords={research.keyword_analysis.primary}
|
||||
color="#1976d2"
|
||||
backgroundColor="#e3f2fd"
|
||||
icon="🎯"
|
||||
showCount={2}
|
||||
tooltip="Core keywords that directly match your main topic. These are the most important terms for SEO and should be naturally integrated throughout your content. Primary keywords typically have high search volume and strong commercial intent."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary Keywords */}
|
||||
{research.keyword_analysis?.secondary && research.keyword_analysis.secondary.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Secondary"
|
||||
keywords={research.keyword_analysis.secondary}
|
||||
color="#7b1fa2"
|
||||
backgroundColor="#f3e5f5"
|
||||
icon="🔗"
|
||||
showCount={2}
|
||||
tooltip="Supporting keywords that complement your primary terms. These help create topic clusters and improve content depth. Secondary keywords often have lower competition but still drive valuable traffic and enhance topical authority."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Long-tail Keywords */}
|
||||
{research.keyword_analysis?.long_tail && research.keyword_analysis.long_tail.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Long-tail"
|
||||
keywords={research.keyword_analysis.long_tail}
|
||||
color="#2e7d32"
|
||||
backgroundColor="#e8f5e8"
|
||||
icon="📏"
|
||||
showCount={2}
|
||||
tooltip="Specific, longer phrases that users search for. These keywords have lower search volume but higher conversion rates and less competition. Long-tail keywords help capture users with specific intent and often lead to better engagement."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Semantic Keywords */}
|
||||
{research.keyword_analysis?.semantic_keywords && research.keyword_analysis.semantic_keywords.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Semantic"
|
||||
keywords={research.keyword_analysis.semantic_keywords}
|
||||
color="#f57c00"
|
||||
backgroundColor="#fff3e0"
|
||||
icon="🧠"
|
||||
showCount={2}
|
||||
tooltip="Contextually related terms that help search engines understand your content's meaning. These keywords improve semantic relevance and help with featured snippets. They're crucial for modern SEO and natural language processing algorithms."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trending Terms */}
|
||||
{research.keyword_analysis?.trending_terms && research.keyword_analysis.trending_terms.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Trending"
|
||||
keywords={research.keyword_analysis.trending_terms}
|
||||
color="#c2185b"
|
||||
backgroundColor="#fce4ec"
|
||||
icon="📈"
|
||||
showCount={2}
|
||||
tooltip="Currently popular and rising search terms in your industry. These keywords can provide opportunities for timely content and increased visibility. Trending terms often have growing search volume and can help you capture emerging market interest."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content Gaps */}
|
||||
{research.keyword_analysis?.content_gaps && research.keyword_analysis.content_gaps.length > 0 && (
|
||||
<KeywordChipGroup
|
||||
title="Content Gaps"
|
||||
keywords={research.keyword_analysis.content_gaps}
|
||||
color="#c62828"
|
||||
backgroundColor="#ffebee"
|
||||
icon="🕳️"
|
||||
showCount={2}
|
||||
tooltip="Underserved topics and keywords that competitors aren't adequately covering. These represent opportunities to create unique, valuable content that can help you stand out. Content gaps often lead to easier ranking opportunities and less saturated markets."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Sources Content */}
|
||||
<div style={{ flex: 2, minWidth: 0, overflow: 'hidden' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Research Sources ({research.sources.length})</h3>
|
||||
|
||||
{/* Research Insights Section */}
|
||||
{research.keyword_analysis?.analysis_insights && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
borderLeft: '4px solid #3b82f6'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>💡</span>
|
||||
<h4 style={{ margin: 0, color: '#1e40af', fontSize: '14px', fontWeight: '600' }}>Research Insights</h4>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics in Research Insights - Moved to right corner */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{research.keyword_analysis?.search_intent && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #0ea5e9',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: '#0369a1', fontSize: '10px' }}>🎯</span>
|
||||
<span style={{ color: '#0369a1' }}>Search Intent:</span>
|
||||
<span style={{
|
||||
color: '#0c4a6e',
|
||||
fontWeight: '600',
|
||||
backgroundColor: '#e0f2fe',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{research.keyword_analysis.search_intent}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{research.keyword_analysis?.difficulty && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #ef4444',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: '#dc2626', fontSize: '10px' }}>📊</span>
|
||||
<span style={{ color: '#dc2626' }}>Difficulty:</span>
|
||||
<span style={{
|
||||
color: '#991b1b',
|
||||
fontWeight: '600',
|
||||
backgroundColor: '#fee2e2',
|
||||
padding: '1px 4px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{research.keyword_analysis.difficulty}/10
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#475569',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{research.keyword_analysis.analysis_insights}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: Google Search Widget is shown in GoogleSearchModal instead */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: '12px',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{research.sources.map((source, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
SERP Ranking {source.index !== undefined ? source.index + 1 : '?'}
|
||||
</span>
|
||||
<span style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
Research Type: {source.source_type || 'web'}
|
||||
</span>
|
||||
{source.published_at && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{source.published_at}
|
||||
</span>
|
||||
)}
|
||||
{!source.published_at && (
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
No date
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333', lineHeight: '1.3' }}>
|
||||
{source.title}
|
||||
</h4>
|
||||
</div>
|
||||
{renderCredibilityScore(source.credibility_score)}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '0 0 6px 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{source.excerpt}
|
||||
</p>
|
||||
<div style={{ fontSize: '11px', color: '#666', marginTop: '6px' }}>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Source from {new URL(source.url).hostname}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchSources;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ResearchSources } from './ResearchSources';
|
||||
export { ResearchGrounding } from './ResearchGrounding';
|
||||
export { GoogleSearchModal } from './GoogleSearchModal';
|
||||
182
frontend/src/components/BlogWriter/ResearchDataActions.tsx
Normal file
182
frontend/src/components/BlogWriter/ResearchDataActions.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchDataActionsProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onOutlineCreated: (outline: BlogOutlineSection[]) => void;
|
||||
onTitleOptionsSet: (titles: string[]) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
research,
|
||||
onOutlineCreated,
|
||||
onTitleOptionsSet,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
// Chat with Research Data
|
||||
useCopilotActionTyped({
|
||||
name: 'chatWithResearchData',
|
||||
description: 'Chat with the research data to explore insights, ask questions, and get recommendations',
|
||||
parameters: [
|
||||
{ name: 'question', type: 'string', description: 'Question or topic to explore in the research data', required: true }
|
||||
],
|
||||
handler: async ({ question }: { question: string }) => {
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available. Please complete research first.',
|
||||
suggestion: 'Try asking: "I want to research a topic for my blog"'
|
||||
};
|
||||
}
|
||||
|
||||
// Provide comprehensive research context for the copilot to answer intelligently
|
||||
const researchContext = {
|
||||
sources: research.sources.length,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
secondaryKeywords: research.keyword_analysis?.secondary || [],
|
||||
longTailKeywords: research.keyword_analysis?.long_tail || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational',
|
||||
contentAngles: research.suggested_angles || [],
|
||||
competitorAnalysis: research.competitor_analysis || {},
|
||||
searchQueries: research.search_queries || [],
|
||||
topSources: research.sources.slice(0, 5).map(s => ({
|
||||
title: s.title,
|
||||
credibility: s.credibility_score,
|
||||
excerpt: s.excerpt?.substring(0, 200)
|
||||
}))
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `I can help you explore the research data! Here's what I found:`,
|
||||
research_context: researchContext,
|
||||
user_question: question,
|
||||
next_step_suggestion: 'Ready to create an outline? Try: "Create outline with custom inputs" or "Let\'s proceed to create an outline"'
|
||||
};
|
||||
},
|
||||
render: ({ status, result }: any) => {
|
||||
if (status === 'complete' && result?.success) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#1976d2' }}>🔍 Research Data Insights</h4>
|
||||
<div style={{ fontSize: '14px', color: '#333', lineHeight: '1.5', marginBottom: '12px' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}><strong>Your Question:</strong> {result.user_question}</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}><strong>Research Summary:</strong></p>
|
||||
<ul style={{ margin: '0 0 8px 0', paddingLeft: '20px' }}>
|
||||
<li>{result.research_context.sources} authoritative sources found</li>
|
||||
<li>Primary keywords: {result.research_context.primaryKeywords.join(', ')}</li>
|
||||
<li>Search intent: {result.research_context.searchIntent}</li>
|
||||
<li>{result.research_context.contentAngles.length} content angles identified</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px 0', fontWeight: '500', color: '#1976d2' }}>💡 Next Step:</p>
|
||||
<p style={{ margin: '0', fontSize: '14px', color: '#333' }}>{result.next_step_suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Create Outline with Custom Inputs
|
||||
useCopilotActionTyped({
|
||||
name: 'createOutlineWithCustomInputs',
|
||||
description: 'Create an outline with custom instructions and requirements from the user',
|
||||
parameters: [
|
||||
{ name: 'customInstructions', type: 'string', description: 'Custom instructions for outline generation', required: true }
|
||||
],
|
||||
handler: async ({ customInstructions }: { customInstructions: string }) => {
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available. Please complete research first.',
|
||||
suggestion: 'Try asking: "I want to research a topic for my blog"'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Create a custom outline request with user instructions
|
||||
const customOutlineRequest = {
|
||||
research: research,
|
||||
word_count: 1500,
|
||||
custom_instructions: customInstructions
|
||||
};
|
||||
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration(customOutlineRequest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Custom outline generation started! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id,
|
||||
next_step_suggestion: 'The outline is being generated based on your custom instructions. You can monitor progress below.'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Custom outline creation failed: ${error}`,
|
||||
suggestion: 'Try providing more specific instructions or ask me to help refine your requirements.'
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #673ab7',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#673ab7' }}>🎨 Creating Custom Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing your custom instructions...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Applying requirements to research data...</p>
|
||||
<p style={{ margin: '0' }}>• Generating tailored outline structure...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit actions, no UI
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface ResearchPollingHandlerProps {
|
||||
taskId: string | null;
|
||||
onResearchComplete: (result: BlogResearchResponse) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
taskId,
|
||||
onResearchComplete,
|
||||
onError
|
||||
}) => {
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
debug.log('[ResearchPollingHandler] progress', { message });
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
debug.log('[ResearchPollingHandler] complete');
|
||||
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete(result);
|
||||
setCurrentMessage('');
|
||||
},
|
||||
onError: (error) => {
|
||||
debug.error('[ResearchPollingHandler] error', error);
|
||||
onError?.(error);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling when taskId is provided
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
polling.startPolling(taskId);
|
||||
} else {
|
||||
// Only stop if actually polling (not on every render when taskId is null)
|
||||
if (polling.isPolling) {
|
||||
polling.stopPolling();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]); // Removed polling from dependencies - usePolling already handles cleanup
|
||||
|
||||
// Only log on meaningful changes
|
||||
useEffect(() => {
|
||||
debug.log('[ResearchPollingHandler] state', {
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressCount: polling.progressMessages?.length || 0
|
||||
});
|
||||
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
|
||||
|
||||
// Render the unified research progress modal when a task is present
|
||||
return (
|
||||
<ResearchProgressModal
|
||||
open={Boolean(taskId)}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => { /* modal is informational during processing; ignore manual close */ }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchPollingHandler;
|
||||
619
frontend/src/components/BlogWriter/ResearchProgressModal.tsx
Normal file
619
frontend/src/components/BlogWriter/ResearchProgressModal.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface ResearchProgressModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
status?: string;
|
||||
messages: Array<{ timestamp: string; message: string }>;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tone = 'info' | 'active' | 'success' | 'warning' | 'error';
|
||||
type StageState = 'upcoming' | 'active' | 'done' | 'error';
|
||||
|
||||
const statusThemes: Record<
|
||||
string,
|
||||
{ label: string; description: string; color: string; background: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Queued',
|
||||
description: 'Preparing the research workflow…',
|
||||
color: '#1f2937',
|
||||
background: '#e5e7eb'
|
||||
},
|
||||
running: {
|
||||
label: 'In Progress',
|
||||
description: 'Gathering sources and extracting insights.',
|
||||
color: '#1d4ed8',
|
||||
background: '#dbeafe'
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
success: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
succeeded: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
finished: {
|
||||
label: 'Completed',
|
||||
description: 'Research results are ready to review.',
|
||||
color: '#047857',
|
||||
background: '#d1fae5'
|
||||
},
|
||||
failed: {
|
||||
label: 'Needs Attention',
|
||||
description: 'We hit an issue while running research.',
|
||||
color: '#b91c1c',
|
||||
background: '#fee2e2'
|
||||
}
|
||||
};
|
||||
|
||||
const toneStyles: Record<Tone, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#f8fafc', border: '#e2e8f0', text: '#0f172a' },
|
||||
active: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
|
||||
success: { bg: '#ecfdf5', border: '#bbf7d0', text: '#047857' },
|
||||
warning: { bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' },
|
||||
error: { bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
|
||||
};
|
||||
|
||||
const stageDefinitions = [
|
||||
{
|
||||
id: 'cache',
|
||||
label: 'Cache Check',
|
||||
description: 'Looking for saved research results to speed things up.',
|
||||
icon: '🗂️',
|
||||
keywords: ['cache', 'cached', 'stored']
|
||||
},
|
||||
{
|
||||
id: 'discovery',
|
||||
label: 'Source Discovery',
|
||||
description: 'Exploring trusted sources across the web.',
|
||||
icon: '🔎',
|
||||
keywords: ['search', 'source', 'gather', 'google', 'discover']
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Insight Extraction',
|
||||
description: 'Extracting data points, statistics, and quotes.',
|
||||
icon: '🧠',
|
||||
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
|
||||
},
|
||||
{
|
||||
id: 'assembly',
|
||||
label: 'Structuring Findings',
|
||||
description: 'Packaging insights and preparing summaries.',
|
||||
icon: '📝',
|
||||
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
|
||||
}
|
||||
] as const;
|
||||
|
||||
type StageId = (typeof stageDefinitions)[number]['id'];
|
||||
|
||||
interface MessageMeta {
|
||||
timestamp: string;
|
||||
timeLabel: string;
|
||||
raw: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
tone: Tone;
|
||||
stage: StageId | null;
|
||||
}
|
||||
|
||||
const completionStatuses = new Set(['completed', 'success', 'succeeded', 'finished']);
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const inferStage = (text: string): StageId | null => {
|
||||
const lower = text.toLowerCase();
|
||||
for (const stage of stageDefinitions) {
|
||||
if (stage.keywords.some(keyword => lower.includes(keyword))) {
|
||||
return stage.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const friendlyMappings: Array<{
|
||||
keywords: string[];
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
tone: Tone;
|
||||
stage?: StageId;
|
||||
}> = [
|
||||
{
|
||||
keywords: ['checking cache', 'cache'],
|
||||
title: 'Checking existing research cache',
|
||||
subtitle: 'Looking for previously generated insights so we can respond instantly.',
|
||||
icon: '🗂️',
|
||||
tone: 'info',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['found cached research', 'loading cached'],
|
||||
title: 'Loaded cached research results',
|
||||
subtitle: 'Serving saved insights to keep things fast.',
|
||||
icon: '⚡',
|
||||
tone: 'success',
|
||||
stage: 'cache'
|
||||
},
|
||||
{
|
||||
keywords: ['starting research'],
|
||||
title: 'Launching fresh research',
|
||||
subtitle: 'Bootstrapping the workflow and validating your request.',
|
||||
icon: '🚀',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
},
|
||||
{
|
||||
keywords: ['search', 'query', 'sources', 'web'],
|
||||
title: 'Collecting authoritative sources',
|
||||
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
|
||||
icon: '🔎',
|
||||
tone: 'active',
|
||||
stage: 'discovery'
|
||||
},
|
||||
{
|
||||
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
|
||||
title: 'Extracting key insights',
|
||||
subtitle: 'Summarising statistics, trends, and quotes that matter.',
|
||||
icon: '🧠',
|
||||
tone: 'active',
|
||||
stage: 'analysis'
|
||||
},
|
||||
{
|
||||
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
|
||||
title: 'Structuring the research package',
|
||||
subtitle: 'Organising findings into ready-to-use sections.',
|
||||
icon: '🧩',
|
||||
tone: 'info',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['completed successfully', 'research completed', 'ready'],
|
||||
title: 'Research completed successfully',
|
||||
subtitle: 'All insights are ready for the outline phase.',
|
||||
icon: '✅',
|
||||
tone: 'success',
|
||||
stage: 'assembly'
|
||||
},
|
||||
{
|
||||
keywords: ['failed', 'error', 'limit exceeded'],
|
||||
title: 'Research encountered an issue',
|
||||
subtitle: 'Review the error message below and try again.',
|
||||
icon: '⚠️',
|
||||
tone: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
|
||||
|
||||
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
|
||||
const raw = message.message || '';
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
const mapping = friendlyMappings.find(entry =>
|
||||
entry.keywords.some(keyword => lower.includes(keyword))
|
||||
);
|
||||
|
||||
if (mapping) {
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
timeLabel: formatTime(message.timestamp),
|
||||
raw,
|
||||
title: mapping.title,
|
||||
subtitle: mapping.subtitle,
|
||||
icon: mapping.icon,
|
||||
tone: mapping.tone,
|
||||
stage: mapping.stage ?? inferStage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
const stage = inferStage(raw);
|
||||
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
timeLabel: formatTime(message.timestamp),
|
||||
raw,
|
||||
title: sanitizeTitle(raw) || 'Update received',
|
||||
icon: '📝',
|
||||
tone: 'info',
|
||||
stage
|
||||
};
|
||||
};
|
||||
|
||||
const stageStateCopy: Record<StageState, { label: string; color: string; background: string; border: string }> = {
|
||||
upcoming: {
|
||||
label: 'Pending',
|
||||
color: '#6b7280',
|
||||
background: '#f3f4f6',
|
||||
border: '#e5e7eb'
|
||||
},
|
||||
active: {
|
||||
label: 'In Progress',
|
||||
color: '#2563eb',
|
||||
background: '#eff6ff',
|
||||
border: '#bfdbfe'
|
||||
},
|
||||
done: {
|
||||
label: 'Completed',
|
||||
color: '#047857',
|
||||
background: '#ecfdf5',
|
||||
border: '#bbf7d0'
|
||||
},
|
||||
error: {
|
||||
label: 'Needs Attention',
|
||||
color: '#b91c1c',
|
||||
background: '#fee2e2',
|
||||
border: '#fecaca'
|
||||
}
|
||||
};
|
||||
|
||||
const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
|
||||
open,
|
||||
title = 'Research in progress',
|
||||
status,
|
||||
messages,
|
||||
error,
|
||||
onClose
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const normalizedStatus = (status || '').toLowerCase();
|
||||
const statusKey = error ? 'failed' : normalizedStatus;
|
||||
const statusInfo = statusThemes[statusKey] || statusThemes.pending;
|
||||
|
||||
const processedMessages = useMemo(() => {
|
||||
if (!messages || messages.length === 0) {
|
||||
return [] as MessageMeta[];
|
||||
}
|
||||
return messages.map(mapMessageToMeta);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [processedMessages.length]);
|
||||
|
||||
const latestMessage = processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null;
|
||||
|
||||
const stagesWithState = useMemo(() => {
|
||||
const states: StageState[] = stageDefinitions.map(() => 'upcoming');
|
||||
let highestCompletedIndex = -1;
|
||||
|
||||
processedMessages.forEach(meta => {
|
||||
if (!meta.stage) {
|
||||
return;
|
||||
}
|
||||
const idx = stageDefinitions.findIndex(stage => stage.id === meta.stage);
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.tone === 'error' || /error|failed/i.test(meta.raw)) {
|
||||
states[idx] = 'error';
|
||||
} else {
|
||||
states[idx] = 'done';
|
||||
if (idx > highestCompletedIndex) {
|
||||
highestCompletedIndex = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
const firstPending = states.findIndex(state => state === 'upcoming');
|
||||
if (firstPending !== -1 && !completionStatuses.has(normalizedStatus)) {
|
||||
states[firstPending] = 'active';
|
||||
} else if (completionStatuses.has(normalizedStatus)) {
|
||||
for (let i = 0; i < states.length; i += 1) {
|
||||
if (states[i] !== 'error') {
|
||||
states[i] = 'done';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (highestCompletedIndex >= 0) {
|
||||
states[highestCompletedIndex] = 'error';
|
||||
}
|
||||
|
||||
return stageDefinitions.map((stage, index) => ({
|
||||
...stage,
|
||||
state: states[index]
|
||||
}));
|
||||
}, [error, normalizedStatus, processedMessages]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="research-progress-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.55)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
padding: '24px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 940,
|
||||
maxHeight: '82vh',
|
||||
background: '#ffffff',
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 28px 80px rgba(15, 23, 42, 0.25)',
|
||||
border: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px 24px 32px',
|
||||
background: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundSize: '35% auto',
|
||||
opacity: 0.12,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 id="research-progress-title" style={{ margin: 0, fontSize: 22, color: '#0f172a' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
|
||||
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 999,
|
||||
background: statusInfo.background,
|
||||
color: statusInfo.color,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${statusInfo.color}1A`
|
||||
}}
|
||||
>
|
||||
<span>{statusInfo.label}</span>
|
||||
<span style={{ fontSize: 12, color: '#475569', fontWeight: 500 }}>{statusInfo.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
border: '1px solid #cbd5f5',
|
||||
borderRadius: 12,
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#1f2937',
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.08)',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 20
|
||||
}}
|
||||
>
|
||||
{stagesWithState.map(stage => {
|
||||
const copy = stageStateCopy[stage.state];
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
style={{
|
||||
flex: '1 1 180px',
|
||||
minWidth: 180,
|
||||
borderRadius: 14,
|
||||
padding: '14px 16px',
|
||||
background: copy.background,
|
||||
border: `1px solid ${copy.border}`,
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600, color: '#0f172a' }}>
|
||||
<span style={{ fontSize: 22 }}>{stage.icon}</span>
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12.5, color: '#475569' }}>{stage.description}</div>
|
||||
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color }}>{copy.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{latestMessage && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
padding: '18px 20px',
|
||||
border: `1px solid ${toneStyles[latestMessage.tone].border}`,
|
||||
background: toneStyles[latestMessage.tone].bg,
|
||||
marginBottom: 20,
|
||||
boxShadow: '0 4px 16px rgba(15, 23, 42, 0.08)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<div style={{ fontSize: 28 }}>{latestMessage.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 16
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a' }}>{latestMessage.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{latestMessage.timeLabel}</div>
|
||||
</div>
|
||||
{latestMessage.subtitle && (
|
||||
<div style={{ marginTop: 6, fontSize: 13.5, color: '#334155' }}>{latestMessage.subtitle}</div>
|
||||
)}
|
||||
{latestMessage.raw && (
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, color: '#64748b' }}>{latestMessage.raw}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 16,
|
||||
padding: '18px 0',
|
||||
maxHeight: '32vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
{processedMessages.length === 0 && (
|
||||
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14 }}>
|
||||
Awaiting progress updates…
|
||||
</div>
|
||||
)}
|
||||
{processedMessages.map((meta, index) => {
|
||||
const styles = toneStyles[meta.tone];
|
||||
return (
|
||||
<div
|
||||
key={`${meta.timestamp}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 14,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 12,
|
||||
background: styles.bg,
|
||||
border: `1px solid ${styles.border}`
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22 }}>{meta.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: styles.text, fontSize: 14 }}>{meta.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{meta.timeLabel}</div>
|
||||
</div>
|
||||
{meta.subtitle && (
|
||||
<div style={{ marginTop: 4, fontSize: 13, color: '#475569' }}>{meta.subtitle}</div>
|
||||
)}
|
||||
{meta.raw && (
|
||||
<div style={{ marginTop: 6, fontSize: 12.5, color: '#6b7280' }}>{meta.raw}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18,
|
||||
padding: '12px 16px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #fecaca',
|
||||
background: '#fef2f2',
|
||||
color: '#b91c1c',
|
||||
fontSize: 13.5
|
||||
}}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchProgressModal;
|
||||
|
||||
588
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
588
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { ResearchSources, ResearchGrounding, GoogleSearchModal } from './ResearchComponents';
|
||||
|
||||
interface ResearchResultsProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) => {
|
||||
const [showAnglesModal, setShowAnglesModal] = useState(false);
|
||||
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
|
||||
const [showGroundingModal, setShowGroundingModal] = useState(false);
|
||||
const [showSearchModal, setShowSearchModal] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
// Show toast message on component mount
|
||||
useEffect(() => {
|
||||
setShowToast(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 4000); // Show for 4 seconds
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const renderAnglesModal = () => {
|
||||
if (!showAnglesModal) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowAnglesModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>💡 Content Angles ({research.suggested_angles.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowAnglesModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.suggested_angles.map((angle, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '500', color: '#333' }}>
|
||||
{angle}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const renderCompetitorModal = () => {
|
||||
if (!showCompetitorModal) return null;
|
||||
|
||||
const ca = research.competitor_analysis || {} as any;
|
||||
const top_competitors: string[] = Array.isArray(ca.top_competitors) ? ca.top_competitors : [];
|
||||
const opportunities: string[] = Array.isArray(ca.opportunities) ? ca.opportunities : [];
|
||||
const competitive_advantages: string[] = Array.isArray(ca.competitive_advantages) ? ca.competitive_advantages : [];
|
||||
const market_positioning: string | undefined = typeof ca.market_positioning === 'string' ? ca.market_positioning : undefined;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowCompetitorModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: 0, color: '#1f2937', fontSize: '24px', fontWeight: '700' }}>📈 Competitor Analysis</h3>
|
||||
<button
|
||||
onClick={() => setShowCompetitorModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px' }}>
|
||||
{/* Summary cards */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
borderLeft: '4px solid #0ea5e9'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#0369a1', fontWeight: '600', marginBottom: '8px' }}>Top Competitors</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#0c4a6e' }}>{top_competitors.length}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||
borderLeft: '4px solid #22c55e'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#15803d', fontWeight: '600', marginBottom: '8px' }}>Opportunities</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#166534' }}>{opportunities.length}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderLeft: '4px solid #f59e0b'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#d97706', fontWeight: '600', marginBottom: '8px' }}>Competitive Advantages</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e' }}>{competitive_advantages.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Market positioning */}
|
||||
{market_positioning && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🎯 Market Positioning</h4>
|
||||
<p style={{ margin: 0, color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>{market_positioning}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lists */}
|
||||
{top_competitors.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🏁 Top Competitors ({top_competitors.length})</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{top_competitors.map((c, i) => (
|
||||
<span key={i} style={{
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
color: '#1e40af',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #93c5fd'
|
||||
}}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opportunities.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🚀 Opportunities ({opportunities.length})</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>
|
||||
{opportunities.map((o, i) => (
|
||||
<li key={i} style={{ marginBottom: '8px' }}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{competitive_advantages.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>✅ Competitive Advantages ({competitive_advantages.length})</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>
|
||||
{competitive_advantages.map((a, i) => (
|
||||
<li key={i} style={{ marginBottom: '8px' }}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroundingModal = () => {
|
||||
if (!showGroundingModal) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => setShowGroundingModal(false)}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: 0, color: '#1f2937', fontSize: '24px', fontWeight: '700' }}>🔗 Grounding Analysis</h3>
|
||||
<button
|
||||
onClick={() => setShowGroundingModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#6b7280',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#6b7280';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grounding Content */}
|
||||
<ResearchGrounding research={research} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
|
||||
📊 Research Results for {research.keywords?.join(', ') || 'Your Topic'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Action Chips */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{/* Competitor Analysis Chip */}
|
||||
<div
|
||||
onClick={() => setShowCompetitorModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
color: '#1e40af',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#dbeafe';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f9ff';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
📈 Competitor Analysis
|
||||
</div>
|
||||
|
||||
{/* Grounding Analysis Chip */}
|
||||
<div
|
||||
onClick={() => setShowGroundingModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #8b5cf6',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3e8ff';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#faf5ff';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
🔗 Grounding Analysis
|
||||
</div>
|
||||
|
||||
{/* Use Research Blog Topics Chip */}
|
||||
<div
|
||||
onClick={() => setShowAnglesModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#2e7d32',
|
||||
border: '1px solid #4caf50',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#c8e6c9';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#e8f5e8';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
📝 Use Research Blog Topics
|
||||
</div>
|
||||
|
||||
{/* Google Search Suggestions Chip - Only show when we have search data */}
|
||||
{(research.search_widget || (research.search_queries && research.search_queries.length > 0)) && (
|
||||
<div
|
||||
onClick={() => setShowSearchModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#fff8e1',
|
||||
color: '#f57c00',
|
||||
border: '1px solid #ffb74d',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#ffe082';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#fff8e1';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
🔍 Google Search
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Toast Message */}
|
||||
{showToast && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
animation: 'slideInRight 0.3s ease-out'
|
||||
}}>
|
||||
<span>✅</span>
|
||||
<span>Google Search grounding analysis completed with {research.sources.length} sources and {research.search_queries?.length || 0} search queries</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Content */}
|
||||
<ResearchSources research={research} />
|
||||
|
||||
{/* Modals */}
|
||||
{renderAnglesModal()}
|
||||
{renderCompetitorModal()}
|
||||
{renderGroundingModal()}
|
||||
|
||||
{/* Google Search Modal */}
|
||||
{showSearchModal && (
|
||||
<GoogleSearchModal
|
||||
research={research}
|
||||
onClose={() => setShowSearchModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchResults;
|
||||
380
frontend/src/components/BlogWriter/RewriteFeedbackForm.tsx
Normal file
380
frontend/src/components/BlogWriter/RewriteFeedbackForm.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
// Type assertion for CopilotKit action
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// Separate component to manage rewrite feedback form state
|
||||
const RewriteFeedbackFormComponent: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { feedback: string; tone?: string; audience?: string; focus?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [tone, setTone] = useState('');
|
||||
const [audience, setAudience] = useState('');
|
||||
const [focus, setFocus] = useState('');
|
||||
const hasValidInput = feedback.trim().length >= 10;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({
|
||||
feedback: feedback.trim(),
|
||||
tone: tone.trim() || undefined,
|
||||
audience: audience.trim() || undefined,
|
||||
focus: focus.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
window.alert('Please provide detailed feedback about what you want to change (at least 10 characters).');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🔄 Let's Rewrite Your Blog
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please provide feedback about what you\'d like to change in your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
What do you want to change? *
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="e.g., I want to focus more on practical applications, make the tone more casual, emphasize real-world examples, etc."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
{feedback.length}/10 characters minimum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Desired Tone (optional)
|
||||
</label>
|
||||
<select
|
||||
value={tone}
|
||||
onChange={(e) => setTone(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="">Keep current tone</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="authoritative">Authoritative</option>
|
||||
<option value="conversational">Conversational</option>
|
||||
<option value="humorous">Humorous</option>
|
||||
<option value="empathetic">Empathetic</option>
|
||||
<option value="academic">Academic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Audience (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
placeholder="e.g., beginners, professionals, students, general audience"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Main Focus/Angle (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={(e) => setFocus(e.target.value)}
|
||||
placeholder="e.g., practical applications, technical deep-dive, beginner-friendly, industry trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🔄 Rewrite Blog {hasValidInput ? '(Enabled)' : '(Disabled)'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface RewriteFeedbackFormProps {
|
||||
research: BlogResearchResponse;
|
||||
outline: BlogOutlineSection[];
|
||||
sections: Record<string, string>;
|
||||
blogTitle: string;
|
||||
onRewriteStarted?: (taskId: string) => void;
|
||||
onRewriteTriggered?: () => void;
|
||||
}
|
||||
|
||||
export const RewriteFeedbackForm: React.FC<RewriteFeedbackFormProps> = ({
|
||||
research,
|
||||
outline,
|
||||
sections,
|
||||
blogTitle,
|
||||
onRewriteStarted,
|
||||
onRewriteTriggered
|
||||
}) => {
|
||||
// Note: isCollectingFeedback state removed as it was unused
|
||||
|
||||
// Rewrite Blog Action with HITL
|
||||
useCopilotActionTyped({
|
||||
name: 'rewriteBlog',
|
||||
description: 'Rewrite the entire blog based on user feedback and preferences',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Rewrite feedback received! Starting blog rewrite...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RewriteFeedbackFormComponent
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
onRewriteTriggered?.();
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Process Rewrite Feedback Action
|
||||
useCopilotActionTyped({
|
||||
name: 'processRewriteFeedback',
|
||||
description: 'Process the rewrite feedback and start the blog rewrite task',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with feedback, tone, audience, and focus', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { feedback, tone, audience, focus } = data;
|
||||
|
||||
if (!feedback || feedback.trim().length < 10) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please provide more detailed feedback about what you\'d like to change.',
|
||||
suggestion: 'Be specific about what aspects of the blog you want to improve, change, or rewrite.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare the rewrite request
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content found to rewrite. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog before attempting to rewrite it.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call the rewrite API
|
||||
const result = await blogWriterApi.rewriteBlog({
|
||||
title: blogTitle,
|
||||
sections: sectionsData,
|
||||
research: research,
|
||||
outline: outline,
|
||||
feedback: feedback.trim(),
|
||||
tone: tone?.trim() || undefined,
|
||||
audience: audience?.trim() || undefined,
|
||||
focus: focus?.trim() || undefined
|
||||
});
|
||||
|
||||
if (result.success && result.taskId) {
|
||||
onRewriteStarted?.(result.taskId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Blog rewrite initiated successfully! Your feedback has been processed and the rewrite is in progress.`,
|
||||
taskId: result.taskId,
|
||||
feedback: {
|
||||
original: feedback,
|
||||
tone: tone || 'Maintain current tone',
|
||||
audience: audience || 'Keep current audience',
|
||||
focus: focus || 'Maintain current focus'
|
||||
},
|
||||
nextStep: 'The rewrite process will take a few moments. You\'ll be notified when it\'s complete.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to initiate blog rewrite.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Collect rewrite feedback error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to process rewrite feedback: ${errorMessage}`,
|
||||
suggestion: 'Please try again or provide more specific feedback about what you\'d like to change.'
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔄 Rewriting Your Blog</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing your feedback and preferences...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Processing current content structure...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Generating improved content with new approach...</p>
|
||||
<p style={{ margin: '0' }}>• Applying tone and audience adjustments...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component doesn't render anything, it just provides actions
|
||||
};
|
||||
|
||||
export default RewriteFeedbackForm;
|
||||
282
frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
Normal file
282
frontend/src/components/BlogWriter/SEO/KeywordAnalysis.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Keyword Analysis Component
|
||||
*
|
||||
* Displays comprehensive keyword analysis including keyword types, densities,
|
||||
* missing keywords, over-optimization, and distribution analysis.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
GpsFixed,
|
||||
Search,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface KeywordAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
keyword_analysis?: {
|
||||
primary_keywords: string[];
|
||||
long_tail_keywords: string[];
|
||||
semantic_keywords: string[];
|
||||
keyword_density: Record<string, number>;
|
||||
keyword_distribution: Record<string, any>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const baseCardSx = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const subCard = (color: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${color}`,
|
||||
background: `linear-gradient(145deg, ${color}14, ${color}1f)`
|
||||
});
|
||||
|
||||
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const keywordData = detailedAnalysis?.keyword_analysis;
|
||||
|
||||
const renderDensityRow = (keyword: string, density: number) => {
|
||||
const status = density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal';
|
||||
const chipColor = density > 3 ? 'error' : density < 1 ? 'warning' : 'success';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
|
||||
{keyword}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{status}
|
||||
</Typography>
|
||||
<Chip label={`${density.toFixed(1)}%`} color={chipColor} size="small" sx={{ fontWeight: 600 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GpsFixed sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Keyword Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Keyword Types Overview */}
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Types Found
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={subCard('rgba(34,197,94,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
|
||||
Primary Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.primary_keywords?.length || 0} found
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={subCard('rgba(59,130,246,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
|
||||
Long-tail Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.long_tail_keywords?.length || 0} found
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ fontWeight: 600, borderColor: '#93c5fd', color: '#1d4ed8' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={subCard('rgba(168,85,247,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
|
||||
Semantic Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.semantic_keywords?.length || 0} found
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" color="secondary" size="small" sx={{ fontWeight: 600, borderColor: '#d8b4fe' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Keyword Densities */}
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Keyword Densities
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Keyword Density Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Shows how frequently each keyword appears in your content as a percentage of total words.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 1-3% for primary keywords
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Too Low (<1%):</strong> Keyword may not be prominent enough
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
|
||||
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
|
||||
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No keyword density data available. Make sure your research data includes target keywords.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Missing Keywords */}
|
||||
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#dc2626' }}>
|
||||
Missing Keywords
|
||||
</Typography>
|
||||
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
|
||||
<IconButton size="small" sx={{ color: '#dc2626' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{keywordData.missing_keywords.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Over-Optimized Keywords */}
|
||||
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#d97706' }}>
|
||||
Over-Optimized Keywords
|
||||
</Typography>
|
||||
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
|
||||
<IconButton size="small" sx={{ color: '#d97706' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{keywordData.over_optimization.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Keyword Distribution Analysis */}
|
||||
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Distribution Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
“{keyword}”
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Density: {data.density?.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
First Occurrence: Character {data.first_occurrence || 'Not found'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Core Metadata Tab Component
|
||||
*
|
||||
* Displays and allows editing of core SEO metadata including:
|
||||
* - SEO Title
|
||||
* - Meta Description
|
||||
* - URL Slug
|
||||
* - Blog Tags
|
||||
* - Blog Categories
|
||||
* - Social Hashtags
|
||||
* - Reading Time
|
||||
* - Focus Keyword
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Chip,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
Check as CheckIcon,
|
||||
Search as SearchIcon,
|
||||
Link as LinkIcon,
|
||||
Tag as TagIcon,
|
||||
Category as CategoryIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
TrendingUp as TrendingUpIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CoreMetadataTabProps {
|
||||
metadata: any;
|
||||
onMetadataEdit: (field: string, value: any) => void;
|
||||
onCopyToClipboard: (text: string, itemId: string) => void;
|
||||
copiedItems: Set<string>;
|
||||
}
|
||||
|
||||
export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
metadata,
|
||||
onMetadataEdit,
|
||||
onCopyToClipboard,
|
||||
copiedItems
|
||||
}) => {
|
||||
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMetadataEdit(field, event.target.value);
|
||||
};
|
||||
|
||||
const handleTagsChange = (field: string) => (event: any) => {
|
||||
const value = typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value;
|
||||
onMetadataEdit(field, value);
|
||||
};
|
||||
|
||||
const getCharacterCountColor = (current: number, max: number) => {
|
||||
if (current > max) return 'error';
|
||||
if (current > max * 0.9) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getCharacterCountText = (current: number, max: number) => {
|
||||
if (current > max) return `${current}/${max} (Too long)`;
|
||||
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
Core SEO Metadata
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* SEO Title */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
SEO Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(metadata.seo_title || '', 'seo_title')}
|
||||
>
|
||||
{copiedItems.has('seo_title') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={metadata.seo_title || ''}
|
||||
onChange={handleTextFieldChange('seo_title')}
|
||||
placeholder="Enter SEO-optimized title (50-60 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((metadata.seo_title || '').length, 60)}
|
||||
>
|
||||
{getCharacterCountText((metadata.seo_title || '').length, 60)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include your primary keyword and keep between 50–60 characters
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Meta Description */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Meta Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(metadata.meta_description || '', 'meta_description')}
|
||||
>
|
||||
{copiedItems.has('meta_description') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={metadata.meta_description || ''}
|
||||
onChange={handleTextFieldChange('meta_description')}
|
||||
placeholder="Enter compelling meta description (150-160 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((metadata.meta_description || '').length, 160)}
|
||||
>
|
||||
{getCharacterCountText((metadata.meta_description || '').length, 160)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Aim for 150–160 characters with a clear value proposition
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* URL Slug */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
URL Slug
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(metadata.url_slug || '', 'url_slug')}
|
||||
>
|
||||
{copiedItems.has('url_slug') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={metadata.url_slug || ''}
|
||||
onChange={handleTextFieldChange('url_slug')}
|
||||
placeholder="seo-friendly-url-slug"
|
||||
helperText="Use lowercase letters, numbers, and hyphens only"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Focus Keyword */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Focus Keyword
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(metadata.focus_keyword || '', 'focus_keyword')}
|
||||
>
|
||||
{copiedItems.has('focus_keyword') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={metadata.focus_keyword || ''}
|
||||
onChange={handleTextFieldChange('focus_keyword')}
|
||||
placeholder="primary-keyword"
|
||||
helperText="Your main SEO keyword for this post"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Tags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Tags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard((metadata.blog_tags || []).join(', '), 'blog_tags')}
|
||||
>
|
||||
{copiedItems.has('blog_tags') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_tags || []}
|
||||
onChange={handleTagsChange('blog_tags')}
|
||||
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{(metadata.blog_tags || []).map((tag: string) => (
|
||||
<MenuItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Add 3–6 relevant tags for better categorization and discoverability
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Categories */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Categories
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard((metadata.blog_categories || []).join(', '), 'blog_categories')}
|
||||
>
|
||||
{copiedItems.has('blog_categories') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_categories || []}
|
||||
onChange={handleTagsChange('blog_categories')}
|
||||
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
<Chip key={value} label={value} size="small" color="primary" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{(metadata.blog_categories || []).map((category: string) => (
|
||||
<MenuItem key={category} value={category}>
|
||||
{category}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Select 1–3 primary categories for your content
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Hashtags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Social Hashtags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard((metadata.social_hashtags || []).join(' '), 'social_hashtags')}
|
||||
>
|
||||
{copiedItems.has('social_hashtags') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.social_hashtags || []}
|
||||
onChange={handleTagsChange('social_hashtags')}
|
||||
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
<Chip key={value} label={value} size="small" color="secondary" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{(metadata.social_hashtags || []).map((hashtag: string) => (
|
||||
<MenuItem key={hashtag} value={hashtag}>
|
||||
{hashtag}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include # symbol (e.g., #multimodalAI). 3–5 hashtags recommended.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Reading Time */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Reading Time
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(`${metadata.reading_time || 0} minutes`, 'reading_time')}
|
||||
>
|
||||
{copiedItems.has('reading_time') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={metadata.reading_time || 0}
|
||||
onChange={handleTextFieldChange('reading_time')}
|
||||
placeholder="5"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
||||
}}
|
||||
helperText="Estimated reading time for your content"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Preview Card Component
|
||||
*
|
||||
* Displays live previews of how the metadata will appear in:
|
||||
* - Search engine results
|
||||
* - Social media platforms
|
||||
* - Rich snippets
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Tabs,
|
||||
Tab,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Code as CodeIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Google as GoogleIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface PreviewCardProps {
|
||||
metadata: any;
|
||||
blogTitle: string;
|
||||
previewTabValue: string;
|
||||
onPreviewTabChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||
metadata,
|
||||
blogTitle,
|
||||
previewTabValue,
|
||||
onPreviewTabChange
|
||||
}) => {
|
||||
const getCurrentDate = () => {
|
||||
return new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Title with Tooltip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Live Preview
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="This is how your blog post will appear in search results and social media platforms"
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Platform Sub-Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={previewTabValue}
|
||||
onChange={(e, newValue) => onPreviewTabChange(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
minHeight: 48
|
||||
},
|
||||
'& .Mui-selected': {
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<GoogleIcon />}
|
||||
iconPosition="start"
|
||||
label="Google Search Results"
|
||||
value="google"
|
||||
/>
|
||||
<Tab
|
||||
icon={<FacebookIcon />}
|
||||
iconPosition="start"
|
||||
label="Facebook Preview"
|
||||
value="facebook"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TwitterIcon />}
|
||||
iconPosition="start"
|
||||
label="Twitter Preview"
|
||||
value="twitter"
|
||||
/>
|
||||
<Tab
|
||||
icon={<CodeIcon />}
|
||||
iconPosition="start"
|
||||
label="Rich Snippets Preview"
|
||||
value="richsnippets"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Google Search Results Preview */}
|
||||
{previewTabValue === 'google' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GoogleIcon sx={{ color: '#4285F4', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Google Search Results
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Google SERP Preview - Light Theme (matches actual Google) */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* URL - Google Blue */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#202124',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
display: 'block',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
||||
</Typography>
|
||||
|
||||
{/* Title - Google Blue, hover underline */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#1a0dab',
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
{metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description - Google Gray */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
lineHeight: 1.58,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
||||
</Typography>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#70757a',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{getCurrentDate()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.reading_time || 5} min read
|
||||
</Typography>
|
||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.blog_tags.slice(0, 2).join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Facebook Preview */}
|
||||
{previewTabValue === 'facebook' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1c1e21' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
<Chip label="Open Graph" size="small" sx={{ bgcolor: '#e7f3ff', color: '#1877F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Facebook Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #dadde1',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f2f3f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #dadde1'
|
||||
}}>
|
||||
{metadata.open_graph?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
fontSize: '12px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '17px',
|
||||
color: '#050505',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Twitter Preview */}
|
||||
{previewTabValue === 'twitter' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f1419' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
<Chip label="Twitter Card" size="small" sx={{ bgcolor: '#e1f5fe', color: '#1DA1F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Twitter Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #eff3f4',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f7f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #eff3f4'
|
||||
}}>
|
||||
{metadata.twitter_card?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
fontSize: '13px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
color: '#0f1419',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
|
||||
{/* Twitter handle */}
|
||||
{metadata.twitter_card?.site && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
mt: 1,
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card.site}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Rich Snippets Preview */}
|
||||
{previewTabValue === 'richsnippets' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<CodeIcon sx={{ color: '#34A853', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Rich Snippets Preview
|
||||
</Typography>
|
||||
<Chip label="JSON-LD Schema" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
{/* Rich Snippets Card */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* Article Schema Preview */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
<Chip label="Article" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
{metadata.json_ld_schema?.author?.name && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
By {metadata.json_ld_schema.author.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.datePublished && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.reading_time && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.reading_time} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.wordCount && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.json_ld_schema.wordCount} words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', display: 'block', mb: 1, fontWeight: 500 }}>
|
||||
Keywords:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={keyword}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: '#e0e0e0', color: '#4d5156' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Social Media Tab Component
|
||||
*
|
||||
* Displays and allows editing of social media metadata including:
|
||||
* - Open Graph tags (Facebook, LinkedIn)
|
||||
* - Twitter Card tags
|
||||
* - Social media previews
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
Check as CheckIcon,
|
||||
Share as ShareIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
Image as ImageIcon,
|
||||
Link as LinkIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface SocialMediaTabProps {
|
||||
metadata: any;
|
||||
onMetadataEdit: (field: string, value: any) => void;
|
||||
onCopyToClipboard: (text: string, itemId: string) => void;
|
||||
copiedItems: Set<string>;
|
||||
}
|
||||
|
||||
export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
metadata,
|
||||
onMetadataEdit,
|
||||
onCopyToClipboard,
|
||||
copiedItems
|
||||
}) => {
|
||||
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMetadataEdit(field, event.target.value);
|
||||
};
|
||||
|
||||
const handleNestedFieldChange = (parentField: string, childField: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentValue = metadata[parentField] || {};
|
||||
onMetadataEdit(parentField, {
|
||||
...currentValue,
|
||||
[childField]: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const getCharacterCountColor = (current: number, max: number) => {
|
||||
if (current > max) return 'error';
|
||||
if (current > max * 0.9) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getCharacterCountText = (current: number, max: number) => {
|
||||
if (current > max) return `${current}/${max} (Too long)`;
|
||||
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const openGraph = metadata.open_graph || {};
|
||||
const twitterCard = metadata.twitter_card || {};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<ShareIcon sx={{ color: 'primary.main' }} />
|
||||
Social Media Metadata
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Open Graph Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<LinkedInIcon sx={{ color: '#0077B5' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Open Graph Tags
|
||||
</Typography>
|
||||
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(openGraph.title || '', 'og_title')}
|
||||
>
|
||||
{copiedItems.has('og_title') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={openGraph.title || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'title')}
|
||||
placeholder="Open Graph title (60 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((openGraph.title || '').length, 60)}
|
||||
>
|
||||
{getCharacterCountText((openGraph.title || '').length, 60)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(openGraph.description || '', 'og_description')}
|
||||
>
|
||||
{copiedItems.has('og_description') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={openGraph.description || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'description')}
|
||||
placeholder="Open Graph description (160 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((openGraph.description || '').length, 160)}
|
||||
>
|
||||
{getCharacterCountText((openGraph.description || '').length, 160)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(openGraph.image || '', 'og_image')}
|
||||
>
|
||||
{copiedItems.has('og_image') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={openGraph.image || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'image')}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<ImageIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(openGraph.url || '', 'og_url')}
|
||||
>
|
||||
{copiedItems.has('og_url') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={openGraph.url || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'url')}
|
||||
placeholder="https://example.com/blog-post"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<LinkIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Twitter Card Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Card Tags
|
||||
</Typography>
|
||||
<Chip label="Twitter & X" size="small" color="info" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(twitterCard.title || '', 'twitter_title')}
|
||||
>
|
||||
{copiedItems.has('twitter_title') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={twitterCard.title || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'title')}
|
||||
placeholder="Twitter card title (70 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((twitterCard.title || '').length, 70)}
|
||||
>
|
||||
{getCharacterCountText((twitterCard.title || '').length, 70)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(twitterCard.description || '', 'twitter_description')}
|
||||
>
|
||||
{copiedItems.has('twitter_description') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={twitterCard.description || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'description')}
|
||||
placeholder="Twitter card description (200 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((twitterCard.description || '').length, 200)}
|
||||
>
|
||||
{getCharacterCountText((twitterCard.description || '').length, 200)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(twitterCard.image || '', 'twitter_image')}
|
||||
>
|
||||
{copiedItems.has('twitter_image') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={twitterCard.image || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'image')}
|
||||
placeholder="https://example.com/twitter-image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<ImageIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Site Handle
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(twitterCard.site || '', 'twitter_site')}
|
||||
>
|
||||
{copiedItems.has('twitter_site') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={twitterCard.site || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'site')}
|
||||
placeholder="@yourwebsite"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<TwitterIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Twitter cards provide rich previews when your content is shared on Twitter/X.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Media Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ShareIcon />
|
||||
Social Media Preview
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* Facebook Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{openGraph.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{openGraph.url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{openGraph.description || 'Your meta description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Twitter Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{twitterCard.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{twitterCard.site || '@yourwebsite'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{twitterCard.description || 'Your Twitter description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Structured Data Tab Component
|
||||
*
|
||||
* Displays and allows editing of JSON-LD structured data including:
|
||||
* - Article schema
|
||||
* - Author information
|
||||
* - Publisher details
|
||||
* - Publication dates
|
||||
* - Keywords and categories
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
InputAdornment,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
Check as CheckIcon,
|
||||
Code as CodeIcon,
|
||||
Person as PersonIcon,
|
||||
Business as BusinessIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
Edit as EditIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface StructuredDataTabProps {
|
||||
metadata: any;
|
||||
onMetadataEdit: (field: string, value: any) => void;
|
||||
onCopyToClipboard: (text: string, itemId: string) => void;
|
||||
copiedItems: Set<string>;
|
||||
}
|
||||
|
||||
export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
metadata,
|
||||
onMetadataEdit,
|
||||
onCopyToClipboard,
|
||||
copiedItems
|
||||
}) => {
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
|
||||
// Helpers for counters and consistent input styling
|
||||
const getCharacterCountColor = (current: number, max: number) => {
|
||||
if (current > max) return 'error';
|
||||
if (current > max * 0.9) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getCharacterCountText = (current: number, max: number) => {
|
||||
if (current > max) return `${current}/${max} (Too long)`;
|
||||
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMetadataEdit(field, event.target.value);
|
||||
};
|
||||
|
||||
const handleNestedFieldChange = (parentField: string, childField: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentValue = metadata[parentField] || {};
|
||||
onMetadataEdit(parentField, {
|
||||
...currentValue,
|
||||
[childField]: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthorFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentSchema = metadata.json_ld_schema || {};
|
||||
const currentAuthor = currentSchema.author || {};
|
||||
onMetadataEdit('json_ld_schema', {
|
||||
...currentSchema,
|
||||
author: {
|
||||
...currentAuthor,
|
||||
[field]: event.target.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePublisherFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentSchema = metadata.json_ld_schema || {};
|
||||
const currentPublisher = currentSchema.publisher || {};
|
||||
onMetadataEdit('json_ld_schema', {
|
||||
...currentSchema,
|
||||
publisher: {
|
||||
...currentPublisher,
|
||||
[field]: event.target.value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSchemaFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentSchema = metadata.json_ld_schema || {};
|
||||
onMetadataEdit('json_ld_schema', {
|
||||
...currentSchema,
|
||||
[field]: event.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const getJsonLdSchema = () => {
|
||||
const schema = metadata.json_ld_schema || {};
|
||||
return JSON.stringify(schema, null, 2);
|
||||
};
|
||||
|
||||
const copyJsonLdSchema = () => {
|
||||
onCopyToClipboard(getJsonLdSchema(), 'json_ld_schema');
|
||||
};
|
||||
|
||||
const jsonLdSchema = metadata.json_ld_schema || {};
|
||||
const author = jsonLdSchema.author || {};
|
||||
const publisher = jsonLdSchema.publisher || {};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon sx={{ color: 'primary.main' }} />
|
||||
Structured Data (JSON-LD)
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Article Information */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Article Schema
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Headline
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.headline || '', 'schema_headline')}
|
||||
>
|
||||
{copiedItems.has('schema_headline') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={jsonLdSchema.headline || ''}
|
||||
onChange={handleSchemaFieldChange('headline')}
|
||||
placeholder="Article headline"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.headline || '').length, 110)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.description || '', 'schema_description')}
|
||||
>
|
||||
{copiedItems.has('schema_description') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={jsonLdSchema.description || ''}
|
||||
onChange={handleSchemaFieldChange('description')}
|
||||
placeholder="Article description"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.description || '').length, 200)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.description || '').length, 200)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Main Entity URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.mainEntityOfPage || '', 'schema_url')}
|
||||
>
|
||||
{copiedItems.has('schema_url') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={jsonLdSchema.mainEntityOfPage || ''}
|
||||
onChange={handleSchemaFieldChange('mainEntityOfPage')}
|
||||
placeholder="https://example.com/blog-post"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<CodeIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.wordCount?.toString() || '', 'schema_wordcount')}
|
||||
>
|
||||
{copiedItems.has('schema_wordcount') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={jsonLdSchema.wordCount || ''}
|
||||
onChange={handleSchemaFieldChange('wordCount')}
|
||||
placeholder="1500"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">words</InputAdornment>
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Author Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonIcon />
|
||||
Author Information
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Author Name
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(author.name || '', 'author_name')}
|
||||
>
|
||||
{copiedItems.has('author_name') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={author.name || ''}
|
||||
onChange={handleAuthorFieldChange('name')}
|
||||
placeholder="Author Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Author Type
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(author['@type'] || '', 'author_type')}
|
||||
>
|
||||
{copiedItems.has('author_type') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={author['@type'] || ''}
|
||||
onChange={handleAuthorFieldChange('@type')}
|
||||
placeholder="Person"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Publisher Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BusinessIcon />
|
||||
Publisher Information
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Publisher Name
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(publisher.name || '', 'publisher_name')}
|
||||
>
|
||||
{copiedItems.has('publisher_name') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={publisher.name || ''}
|
||||
onChange={handlePublisherFieldChange('name')}
|
||||
placeholder="Publisher Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Publisher Logo
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(publisher.logo || '', 'publisher_logo')}
|
||||
>
|
||||
{copiedItems.has('publisher_logo') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={publisher.logo || ''}
|
||||
onChange={handlePublisherFieldChange('logo')}
|
||||
placeholder="https://example.com/logo.png"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Publication Dates */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarIcon />
|
||||
Publication Dates
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Date Published
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.datePublished || '', 'date_published')}
|
||||
>
|
||||
{copiedItems.has('date_published') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="datetime-local"
|
||||
value={jsonLdSchema.datePublished || ''}
|
||||
onChange={handleSchemaFieldChange('datePublished')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Date Modified
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard(jsonLdSchema.dateModified || '', 'date_modified')}
|
||||
>
|
||||
{copiedItems.has('date_modified') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="datetime-local"
|
||||
value={jsonLdSchema.dateModified || ''}
|
||||
onChange={handleSchemaFieldChange('dateModified')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Keywords */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Keywords & Categories
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Keywords
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onCopyToClipboard((jsonLdSchema.keywords || []).join(', '), 'schema_keywords')}
|
||||
>
|
||||
{copiedItems.has('schema_keywords') ? <CheckIcon color="success" /> : <CopyIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={(jsonLdSchema.keywords || []).join(', ')}
|
||||
onChange={(e) => {
|
||||
const keywords = e.target.value.split(',').map(k => k.trim()).filter(k => k);
|
||||
handleSchemaFieldChange('keywords')({ target: { value: keywords } } as any);
|
||||
}}
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
helperText="Separate keywords with commas"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Raw JSON View */}
|
||||
<Grid item xs={12}>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Raw JSON-LD Schema
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Complete JSON-LD Schema
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={copiedItems.has('json_ld_schema') ? <CheckIcon /> : <CopyIcon />}
|
||||
onClick={copyJsonLdSchema}
|
||||
>
|
||||
{copiedItems.has('json_ld_schema') ? 'Copied!' : 'Copy JSON'}
|
||||
</Button>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={15}
|
||||
value={getJsonLdSchema()}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
sx: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0'
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Information Alert */}
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
<Typography variant="body2">
|
||||
<strong>JSON-LD Structured Data:</strong> This schema helps search engines understand your content
|
||||
and may enable rich snippets in search results. The data follows Schema.org Article guidelines.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* OverallScoreCard Component
|
||||
*
|
||||
* Renders the compact overall SEO score summary with grade chip and
|
||||
* category score tiles.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Chip,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
|
||||
interface MetricTooltip {
|
||||
title: string;
|
||||
description: string;
|
||||
methodology: string;
|
||||
score_meaning: string;
|
||||
examples: string;
|
||||
}
|
||||
|
||||
interface OverallScoreCardProps {
|
||||
overallScore: number;
|
||||
overallGrade: string;
|
||||
statusLabel: string;
|
||||
categoryScores: Record<string, number>;
|
||||
getMetricTooltip: (category: string) => MetricTooltip;
|
||||
getScoreColor: (score: number) => string;
|
||||
}
|
||||
|
||||
const getGradeMeta = (grade: string) => {
|
||||
switch (grade) {
|
||||
case 'A':
|
||||
return {
|
||||
color: '#16a34a',
|
||||
background: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.18))',
|
||||
tooltip: 'Grade A: Outstanding SEO health with only minor optimizations needed.'
|
||||
};
|
||||
case 'B':
|
||||
return {
|
||||
color: '#0ea5e9',
|
||||
background: 'linear-gradient(135deg, rgba(14,165,233,0.12), rgba(2,132,199,0.18))',
|
||||
tooltip: 'Grade B: Strong SEO foundation with several opportunities to optimize further.'
|
||||
};
|
||||
case 'C':
|
||||
return {
|
||||
color: '#d97706',
|
||||
background: 'linear-gradient(135deg, rgba(251,191,36,0.14), rgba(217,119,6,0.2))',
|
||||
tooltip: 'Grade C: Moderate SEO performance. Prioritize improvements in weaker categories.'
|
||||
};
|
||||
case 'D':
|
||||
return {
|
||||
color: '#ea580c',
|
||||
background: 'linear-gradient(135deg, rgba(251,113,133,0.14), rgba(249,115,22,0.2))',
|
||||
tooltip: 'Grade D: Significant SEO gaps detected. Address critical issues promptly.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: '#475569',
|
||||
background: 'linear-gradient(135deg, rgba(148,163,184,0.14), rgba(100,116,139,0.2))',
|
||||
tooltip: 'SEO grade unavailable. Review analysis details for more information.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const OverallScoreCard: React.FC<OverallScoreCardProps> = ({
|
||||
overallScore,
|
||||
overallGrade,
|
||||
statusLabel,
|
||||
categoryScores,
|
||||
getMetricTooltip,
|
||||
getScoreColor
|
||||
}) => {
|
||||
const gradeMeta = getGradeMeta(overallGrade);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
sx={{
|
||||
pb: 0,
|
||||
'& .MuiCardHeader-content': {
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}
|
||||
>
|
||||
Overall SEO Performance Snapshot
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
pt: 2,
|
||||
pb: { xs: 2.5, md: 3 },
|
||||
px: { xs: 2, md: 3 }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
alignItems: { xs: 'stretch', md: 'flex-start' }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: { md: 240 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: { xs: 'flex-start', md: 'center' },
|
||||
gap: 1.5,
|
||||
background: 'linear-gradient(145deg, rgba(241,245,249,0.7), rgba(255,255,255,0.95))',
|
||||
borderRadius: 2,
|
||||
p: { xs: 1.5, md: 2 }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: { xs: 'left', md: 'center' } }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1,
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.4rem', md: '2.8rem' },
|
||||
lineHeight: 1,
|
||||
background: 'linear-gradient(120deg, #22c55e, #4ade80)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{overallScore}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 600 }}
|
||||
>
|
||||
/100
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.5 }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={gradeMeta.tooltip} arrow placement="top">
|
||||
<Chip
|
||||
label={statusLabel}
|
||||
avatar={
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: '#fff',
|
||||
color: gradeMeta.color,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{overallGrade}
|
||||
</Avatar>
|
||||
}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
px: 2.2,
|
||||
py: 0.5,
|
||||
letterSpacing: 0.3,
|
||||
color: gradeMeta.color,
|
||||
background: gradeMeta.background
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, minmax(110px, 1fr))', sm: 'repeat(3, minmax(110px, 1fr))' },
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
{Object.entries(categoryScores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Tooltip
|
||||
key={category}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.75, color: '#475569' }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 8px 18px rgba(15,23,42,0.06)',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, color: getScoreColor(score), mb: 0.35 }}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', textTransform: 'capitalize', fontWeight: 600 }}
|
||||
>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverallScoreCard;
|
||||
103
frontend/src/components/BlogWriter/SEO/README.md
Normal file
103
frontend/src/components/BlogWriter/SEO/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SEO Components
|
||||
|
||||
This folder contains extracted SEO analysis components that were refactored from the main `SEOAnalysisModal` component to improve maintainability and code organization.
|
||||
|
||||
## Components
|
||||
|
||||
### KeywordAnalysis
|
||||
- **File**: `KeywordAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive keyword analysis including:
|
||||
- Keyword types overview (primary, long-tail, semantic)
|
||||
- Keyword density analysis with optimal range indicators
|
||||
- Missing keywords detection
|
||||
- Over-optimized keywords detection
|
||||
- Keyword distribution analysis
|
||||
|
||||
### ReadabilityAnalysis
|
||||
- **File**: `ReadabilityAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive readability analysis including:
|
||||
- 6 different readability metrics with tooltips
|
||||
- Content statistics (word count, sections, paragraphs, etc.)
|
||||
- Sentence and paragraph analysis
|
||||
- Target audience determination
|
||||
- Content quality metrics
|
||||
|
||||
### StructureAnalysis
|
||||
- **File**: `StructureAnalysis.tsx`
|
||||
- **Purpose**: Displays comprehensive content structure analysis including:
|
||||
- Structure overview (sections, paragraphs, sentences, structure score)
|
||||
- Content elements detection (introduction, conclusion, call-to-action)
|
||||
- Heading structure analysis (H1, H2, H3 counts and actual headings)
|
||||
- Heading hierarchy score
|
||||
|
||||
### Recommendations
|
||||
- **File**: `Recommendations.tsx`
|
||||
- **Purpose**: Displays actionable SEO recommendations including:
|
||||
- Priority-based recommendation cards (High, Medium, Low)
|
||||
- Category tags for each recommendation
|
||||
- Impact descriptions
|
||||
- Visual priority indicators with icons
|
||||
|
||||
### SEOProcessor
|
||||
- **File**: `SEOProcessor.tsx`
|
||||
- **Purpose**: Provides CopilotKit actions for SEO functionality including:
|
||||
- `generateSEOMetadata` - Generate SEO metadata for blog content
|
||||
- `optimizeSection` - Optimize individual sections for SEO
|
||||
- Interactive UI components for user feedback
|
||||
|
||||
## Refactoring Benefits
|
||||
|
||||
1. **Improved Maintainability**: Each component is focused on a single responsibility
|
||||
2. **Better Code Organization**: Related functionality is grouped together
|
||||
3. **Easier Testing**: Individual components can be tested in isolation
|
||||
4. **Reusability**: Components can be reused in other parts of the application
|
||||
5. **Reduced File Size**: Main modal component reduced by ~600+ lines
|
||||
6. **Modular Architecture**: Clean separation of concerns
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
KeywordAnalysis,
|
||||
ReadabilityAnalysis,
|
||||
StructureAnalysis,
|
||||
Recommendations,
|
||||
SEOProcessor
|
||||
} from './SEO';
|
||||
|
||||
// In your component
|
||||
<KeywordAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
<ReadabilityAnalysis
|
||||
detailedAnalysis={analysisResult.detailed_analysis}
|
||||
visualizationData={analysisResult.visualization_data}
|
||||
/>
|
||||
<StructureAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
<Recommendations recommendations={analysisResult.actionable_recommendations} />
|
||||
<SEOProcessor
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
seoMetadata={seoMetadata}
|
||||
onSEOAnalysis={onSEOAnalysis}
|
||||
onSEOMetadata={onSEOMetadata}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### KeywordAnalysis Props
|
||||
- `detailedAnalysis?: { keyword_analysis?: {...} }` - Detailed analysis data from backend
|
||||
|
||||
### ReadabilityAnalysis Props
|
||||
- `detailedAnalysis?: { readability_analysis?: {...}, content_quality?: {...}, content_structure?: {...} }` - Detailed analysis data
|
||||
- `visualizationData?: { content_stats?: {...} }` - Visualization data for fallback values
|
||||
|
||||
### StructureAnalysis Props
|
||||
- `detailedAnalysis?: { content_structure?: {...}, heading_structure?: {...} }` - Detailed analysis data
|
||||
|
||||
### Recommendations Props
|
||||
- `recommendations: Recommendation[]` - Array of actionable recommendations with priority and impact
|
||||
|
||||
### SEOProcessor Props
|
||||
- `buildFullMarkdown: () => string` - Function to build full markdown content
|
||||
- `seoMetadata: BlogSEOMetadataResponse | null` - Current SEO metadata
|
||||
- `onSEOAnalysis: (analysis: any) => void` - Callback for SEO analysis results
|
||||
- `onSEOMetadata: (metadata: BlogSEOMetadataResponse) => void` - Callback for SEO metadata results
|
||||
319
frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
Normal file
319
frontend/src/components/BlogWriter/SEO/ReadabilityAnalysis.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Readability Analysis Component
|
||||
*
|
||||
* Displays comprehensive readability analysis including readability metrics,
|
||||
* content statistics, sentence/paragraph analysis, and target audience information.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
MenuBook
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ReadabilityAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
readability_analysis?: {
|
||||
metrics: Record<string, number>;
|
||||
avg_sentence_length: number;
|
||||
avg_paragraph_length: number;
|
||||
readability_score: number;
|
||||
target_audience: string;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_quality?: {
|
||||
word_count: number;
|
||||
unique_words: number;
|
||||
vocabulary_diversity: number;
|
||||
transition_words_used: number;
|
||||
content_depth_score: number;
|
||||
flow_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
visualizationData?: {
|
||||
content_stats: {
|
||||
word_count: number;
|
||||
sections: number;
|
||||
paragraphs: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyles = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 30px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const sectionTitleSx = {
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
color: '#0f172a',
|
||||
mb: 2
|
||||
} as const;
|
||||
|
||||
const statRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 0.5
|
||||
} as const;
|
||||
|
||||
const statLabelSx = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValueSx = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const metricRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'help',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 10px 20px rgba(15,23,42,0.08)'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
}) => {
|
||||
const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
|
||||
|
||||
const getMetricDetails = (metric: string, value: number) => {
|
||||
const tooltips: Record<string, { description: string; interpretation: string }> = {
|
||||
flesch_reading_ease: {
|
||||
description: 'Measures how easy text is to read (0-100 scale).',
|
||||
interpretation: value >= 80 ? 'Very Easy' : value >= 60 ? 'Standard' : 'Challenging'
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: 'U.S. grade level required to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
gunning_fog: {
|
||||
description: 'Years of formal education needed for comprehension.',
|
||||
interpretation: value <= 12 ? 'Easy' : value <= 16 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
smog_index: {
|
||||
description: 'Estimates the years of education needed to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
automated_readability: {
|
||||
description: 'Automated readability score based on characters per word.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
coleman_liau: {
|
||||
description: 'Readability based on characters per word and sentence length.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
tooltips[metric] || {
|
||||
description: 'Readability metric',
|
||||
interpretation: 'No interpretation available'
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatRow = (label: React.ReactNode, value: React.ReactNode) => (
|
||||
<Box sx={statRowSx}>
|
||||
<Typography variant="body2" sx={statLabelSx}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={statValueSx}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<MenuBook sx={{ color: 'primary.main' }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h3"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.3, color: '#0f172a' }}
|
||||
>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Readability Metrics
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Measures how easy your content is to read and understand.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Sentence Length:</strong> 15-20 words is optimal
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<MenuBook fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{Object.keys(readabilityMetrics).length > 0 ? (
|
||||
Object.entries(readabilityMetrics).map(([metric, value]) => {
|
||||
const { description, interpretation } = getMetricDetails(metric, value);
|
||||
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={metric}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
<strong>Interpretation:</strong> {interpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={metricRowSx}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
|
||||
{metric.replace('_', ' ')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{value.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No readability metrics available. This may indicate an issue with the content analysis.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Content Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
|
||||
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
|
||||
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
|
||||
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
|
||||
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
|
||||
{renderStatRow(
|
||||
'Vocabulary Diversity',
|
||||
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
|
||||
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Sentence & Paragraph Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow(
|
||||
'Average Sentence Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Average Paragraph Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Transition Words Used',
|
||||
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
|
||||
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
|
||||
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
137
frontend/src/components/BlogWriter/SEO/Recommendations.tsx
Normal file
137
frontend/src/components/BlogWriter/SEO/Recommendations.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Recommendations Component
|
||||
*
|
||||
* Displays actionable SEO recommendations with priority indicators,
|
||||
* category tags, and impact descriptions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Warning
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Recommendation {
|
||||
category: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
interface RecommendationsProps {
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
const priorityStyles: Record<string, { color: string; gradient: string }> = {
|
||||
High: { color: '#dc2626', gradient: 'linear-gradient(135deg, rgba(248,113,113,0.12), rgba(239,68,68,0.18))' },
|
||||
Medium: { color: '#d97706', gradient: 'linear-gradient(135deg, rgba(251,191,36,0.12), rgba(217,119,6,0.16))' },
|
||||
Low: { color: '#16a34a', gradient: 'linear-gradient(135deg, rgba(74,222,128,0.12), rgba(22,163,74,0.16))' },
|
||||
default: { color: '#475569', gradient: 'linear-gradient(135deg, rgba(148,163,184,0.1), rgba(100,116,139,0.14))' }
|
||||
};
|
||||
|
||||
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
|
||||
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High':
|
||||
return <Cancel sx={{ fontSize: 18 }} />;
|
||||
case 'Medium':
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
case 'Low':
|
||||
return <CheckCircle sx={{ fontSize: 18 }} />;
|
||||
default:
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getChipColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High':
|
||||
return 'error';
|
||||
case 'Medium':
|
||||
return 'warning';
|
||||
case 'Low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Lightbulb sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Actionable Recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{recommendations.map((rec, index) => {
|
||||
const styles = priorityStyles[rec.priority] || priorityStyles.default;
|
||||
return (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: styles.gradient,
|
||||
color: getPriorityColor(rec.priority)
|
||||
}}
|
||||
>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: '#cbd5f5', color: '#475569', fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getChipColor(rec.priority)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ lineHeight: 1.6, color: '#1f2937' }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
78
frontend/src/components/BlogWriter/SEO/SEOProcessor.tsx
Normal file
78
frontend/src/components/BlogWriter/SEO/SEOProcessor.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
|
||||
import { apiClient } from '../../../api/client';
|
||||
|
||||
interface SEOProcessorProps {
|
||||
buildFullMarkdown: () => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
onSEOAnalysis: (analysis: any) => void;
|
||||
onSEOMetadata: (metadata: BlogSEOMetadataResponse) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const SEOProcessor: React.FC<SEOProcessorProps> = ({
|
||||
buildFullMarkdown,
|
||||
seoMetadata,
|
||||
onSEOAnalysis,
|
||||
onSEOMetadata
|
||||
}) => {
|
||||
// Removed old runSEOAnalyze action - now using runComprehensiveSEOAnalysis in BlogWriter.tsx
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate SEO metadata for the full draft',
|
||||
parameters: [ { name: 'title', type: 'string', description: 'Preferred title', required: false } ],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoMetadata({ content, title, keywords: [] });
|
||||
onSEOMetadata(res);
|
||||
return { success: true };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond }: any) => (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>SEO Metadata Ready</div>
|
||||
<div style={{ marginBottom: 8 }}>Review the generated title, meta description, and OG/Twitter tags in the editor.</div>
|
||||
<button onClick={() => respond?.('accept')}>Accept Metadata</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeSection',
|
||||
description: 'Optimize a section for readability/EEAT/examples/data with HITL diff',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'Section ID', required: true },
|
||||
{ name: 'goals', type: 'string', description: 'Comma-separated goals', required: false },
|
||||
],
|
||||
handler: async ({ sectionId, goals }: { sectionId: string; goals?: string }) => {
|
||||
const current = buildFullMarkdown();
|
||||
if (!current) return { success: false, message: 'No content yet for this section' };
|
||||
|
||||
// Use comprehensive SEO analysis endpoint
|
||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
content: current,
|
||||
keywords: []
|
||||
});
|
||||
|
||||
const res = response.data;
|
||||
onSEOAnalysis(res);
|
||||
return { success: true, message: 'Analysis ready' };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: any) => {
|
||||
if (status === 'complete') return <div>Optimization applied.</div>;
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Optimization preview</div>
|
||||
<div style={{ marginBottom: 8 }}>Goals: {args.goals || 'readability, EEAT'}</div>
|
||||
<button onClick={() => respond?.('apply')}>Apply Changes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default SEOProcessor;
|
||||
434
frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
Normal file
434
frontend/src/components/BlogWriter/SEO/StructureAnalysis.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Structure Analysis Component
|
||||
*
|
||||
* Displays comprehensive content structure analysis including structure overview,
|
||||
* content elements detection, and heading structure analysis.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
BarChart
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface StructureAnalysisProps {
|
||||
detailedAnalysis?: {
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_quality?: {
|
||||
word_count: number;
|
||||
unique_words: number;
|
||||
vocabulary_diversity: number;
|
||||
transition_words_used: number;
|
||||
content_depth_score: number;
|
||||
flow_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
heading_structure?: {
|
||||
h1_count: number;
|
||||
h2_count: number;
|
||||
h3_count: number;
|
||||
h1_headings: string[];
|
||||
h2_headings: string[];
|
||||
h3_headings: string[];
|
||||
heading_hierarchy_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const baseCard = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const infoRow = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 0',
|
||||
cursor: 'help'
|
||||
} as const;
|
||||
|
||||
const statLabel = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValue = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const highlightCard = (borderColor: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${borderColor}`,
|
||||
background: `linear-gradient(140deg, ${borderColor}15, ${borderColor}22)`
|
||||
});
|
||||
|
||||
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const structure = detailedAnalysis?.content_structure;
|
||||
const quality = detailedAnalysis?.content_quality;
|
||||
const headings = detailedAnalysis?.heading_structure;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Content Structure Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
{/* Content Structure Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Structure Overview
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sections
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of main content sections (H2 headings) in your blog post.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sections || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Paragraphs
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of paragraphs in your content (excluding headings).
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_paragraphs || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Total number of sentences in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Structure Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Overall score (0-100) for your content's structural organization.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/conclusion presence.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.structure_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Elements */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Elements
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Tooltip
|
||||
title="Whether your content has a clear introduction that sets context and expectations."
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
|
||||
<Chip
|
||||
label={structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={structure?.has_introduction ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title="Whether your content ends with a clear conclusion summarizing key points."
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={structure?.has_conclusion ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title="Whether your content includes a clear call to action for readers."
|
||||
arrow
|
||||
>
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={structure?.has_call_to_action ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Content Quality Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Quality Metrics
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Total number of words in your content. Longer content typically ranks better."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.word_count || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Ratio of unique words to total words, indicating content variety and richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
Vocabulary Diversity
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.vocabulary_diversity !== undefined
|
||||
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Score (0-100) indicating how comprehensive and detailed your content is."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
|
||||
Content Depth Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Score (0-100) indicating how well your content flows from one idea to the next."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
|
||||
Flow Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Number of transition words used – higher values suggest smoother narrative flow."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
|
||||
Transition Words Used
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title="Average unique words used throughout the article. Indicates lexical richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
|
||||
Unique Words
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Heading Structure */}
|
||||
{headings && (
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Heading Structure
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
H1 Headings
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h1_count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
H2 Headings
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h2_count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0ea5e9', mb: 1 }}>
|
||||
H3 Headings
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h3_count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
11
frontend/src/components/BlogWriter/SEO/index.ts
Normal file
11
frontend/src/components/BlogWriter/SEO/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* SEO Components Index
|
||||
*
|
||||
* Exports all SEO-related components for easy importing.
|
||||
*/
|
||||
|
||||
export { KeywordAnalysis } from './KeywordAnalysis';
|
||||
export { ReadabilityAnalysis } from './ReadabilityAnalysis';
|
||||
export { StructureAnalysis } from './StructureAnalysis';
|
||||
export { Recommendations } from './Recommendations';
|
||||
export { SEOProcessor } from './SEOProcessor';
|
||||
735
frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
Normal file
735
frontend/src/components/BlogWriter/SEOAnalysisModal.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* SEO Analysis Modal Component
|
||||
*
|
||||
* Displays comprehensive SEO analysis results with visual charts and actionable recommendations.
|
||||
* Integrates with CopilotKit for real-time progress updates and user interactions.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Button,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Typography,
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
Grid,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { apiClient, triggerSubscriptionError } from '../../api/client';
|
||||
import {
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Warning,
|
||||
TrendingUp,
|
||||
Search,
|
||||
Refresh,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
||||
import OverallScoreCard from './SEO/OverallScoreCard';
|
||||
|
||||
interface SEOAnalysisResult {
|
||||
overall_score: number;
|
||||
category_scores: {
|
||||
structure: number;
|
||||
keywords: number;
|
||||
readability: number;
|
||||
quality: number;
|
||||
headings: number;
|
||||
ai_insights: number;
|
||||
};
|
||||
analysis_summary: {
|
||||
overall_grade: string;
|
||||
status: string;
|
||||
strongest_category: string;
|
||||
weakest_category: string;
|
||||
key_strengths: string[];
|
||||
key_weaknesses: string[];
|
||||
ai_summary: string;
|
||||
};
|
||||
actionable_recommendations: Array<{
|
||||
category: string;
|
||||
priority: 'High' | 'Medium' | 'Low';
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}>;
|
||||
visualization_data: {
|
||||
score_radar: {
|
||||
categories: string[];
|
||||
scores: number[];
|
||||
max_score: number;
|
||||
};
|
||||
keyword_analysis: {
|
||||
densities: Record<string, number>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
};
|
||||
readability_metrics: Record<string, number>;
|
||||
content_stats: {
|
||||
word_count: number;
|
||||
sections: number;
|
||||
paragraphs: number;
|
||||
};
|
||||
};
|
||||
detailed_analysis?: {
|
||||
content_structure?: {
|
||||
total_sections: number;
|
||||
total_paragraphs: number;
|
||||
total_sentences: number;
|
||||
has_introduction: boolean;
|
||||
has_conclusion: boolean;
|
||||
has_call_to_action: boolean;
|
||||
structure_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
keyword_analysis?: {
|
||||
primary_keywords: string[];
|
||||
long_tail_keywords: string[];
|
||||
semantic_keywords: string[];
|
||||
keyword_density: Record<string, number>;
|
||||
keyword_distribution: Record<string, any>;
|
||||
missing_keywords: string[];
|
||||
over_optimization: string[];
|
||||
recommendations: string[];
|
||||
};
|
||||
readability_analysis?: {
|
||||
metrics: Record<string, number>;
|
||||
avg_sentence_length: number;
|
||||
avg_paragraph_length: number;
|
||||
readability_score: number;
|
||||
target_audience: string;
|
||||
recommendations: string[];
|
||||
};
|
||||
content_quality?: {
|
||||
word_count: number;
|
||||
unique_words: number;
|
||||
vocabulary_diversity: number;
|
||||
transition_words_used: number;
|
||||
content_depth_score: number;
|
||||
flow_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
heading_structure?: {
|
||||
h1_count: number;
|
||||
h2_count: number;
|
||||
h3_count: number;
|
||||
h1_headings: string[];
|
||||
h2_headings: string[];
|
||||
h3_headings: string[];
|
||||
heading_hierarchy_score: number;
|
||||
recommendations: string[];
|
||||
};
|
||||
};
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
interface SEOAnalysisModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
blogContent: string;
|
||||
blogTitle?: string;
|
||||
researchData: any;
|
||||
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
|
||||
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,
|
||||
onClose,
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
onApplyRecommendations,
|
||||
onAnalysisComplete
|
||||
}) => {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('recommendations');
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
}
|
||||
}, [isOpen, blogContent?.length, researchData]);
|
||||
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
// Prevent multiple simultaneous calls
|
||||
if (isAnalyzing && !forceRefresh) {
|
||||
console.log('⏸️ SEO analysis already in progress, skipping duplicate call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressMessage('Checking cache for previous SEO analysis...');
|
||||
|
||||
// Cache check - always check cache first unless force refresh is requested
|
||||
// Compute hash if not already available
|
||||
let hash = contentHash;
|
||||
if (!hash) {
|
||||
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
// Update state for future use
|
||||
setContentHash(hash);
|
||||
}
|
||||
const cacheKey = getSeoCacheKey(hash, blogTitle);
|
||||
console.log('🔍 Checking SEO cache', {
|
||||
cacheKey,
|
||||
hasHash: !!hash,
|
||||
forceRefresh,
|
||||
hashLength: hash?.length,
|
||||
titleLength: blogTitle?.length,
|
||||
contentLength: blogContent?.length
|
||||
});
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOAnalysisResult;
|
||||
// Validate cached data has required fields
|
||||
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
|
||||
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
|
||||
setAnalysisResult(parsed);
|
||||
setIsAnalyzing(false);
|
||||
setProgress(100);
|
||||
setProgressMessage('SEO analysis loaded from cache');
|
||||
// Notify parent that analysis is complete (from cache)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(parsed);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.warn('⚠️ Cached SEO analysis data is invalid, will fetch fresh analysis');
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('⚠️ Failed to parse cached SEO analysis, will fetch fresh analysis', parseError);
|
||||
// Remove invalid cache entry
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No cached SEO analysis found, will fetch from API', { cacheKey });
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 Force refresh requested, skipping cache check');
|
||||
}
|
||||
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulated progress
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
||||
{ progress: 70, message: 'Generating AI-powered insights...' },
|
||||
{ progress: 90, message: 'Compiling analysis results...' },
|
||||
{ progress: 100, message: 'SEO analysis completed!' }
|
||||
];
|
||||
|
||||
for (const stage of progressStages) {
|
||||
setProgress(stage.progress);
|
||||
setProgressMessage(stage.message);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Backend call
|
||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
blog_content: blogContent,
|
||||
blog_title: blogTitle,
|
||||
research_data: researchData
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
|
||||
|
||||
const convertedResult: SEOAnalysisResult = {
|
||||
overall_score: result.overall_score,
|
||||
category_scores: {
|
||||
structure: result.category_scores?.structure || 0,
|
||||
keywords: result.category_scores?.keywords || 0,
|
||||
readability: result.category_scores?.readability || 0,
|
||||
quality: result.category_scores?.quality || 0,
|
||||
headings: result.category_scores?.headings || 0,
|
||||
ai_insights: result.category_scores?.ai_insights || 0
|
||||
},
|
||||
analysis_summary: result.analysis_summary || {
|
||||
overall_grade: result.overall_score >= 80 ? 'A' : result.overall_score >= 60 ? 'B' : 'C',
|
||||
status: result.overall_score >= 80 ? 'Excellent' : result.overall_score >= 60 ? 'Good' : 'Needs Improvement',
|
||||
strongest_category: 'structure',
|
||||
weakest_category: 'keywords',
|
||||
key_strengths: ['Good content structure', 'Appropriate length'],
|
||||
key_weaknesses: ['Keyword optimization needs work'],
|
||||
ai_summary: 'Content provides good value with room for SEO improvements.'
|
||||
},
|
||||
actionable_recommendations: (result.actionable_recommendations || []).map((rec: any) => ({
|
||||
category: rec.category || 'General',
|
||||
priority: rec.priority || 'Medium' as const,
|
||||
recommendation: rec.recommendation || rec,
|
||||
impact: rec.impact || 'Improves SEO performance'
|
||||
})),
|
||||
visualization_data: {
|
||||
score_radar: {
|
||||
categories: ['structure', 'keywords', 'readability', 'quality', 'headings', 'ai_insights'],
|
||||
scores: [
|
||||
result.category_scores?.structure || 0,
|
||||
result.category_scores?.keywords || 0,
|
||||
result.category_scores?.readability || 0,
|
||||
result.category_scores?.quality || 0,
|
||||
result.category_scores?.headings || 0,
|
||||
result.category_scores?.ai_insights || 0
|
||||
],
|
||||
max_score: 100
|
||||
},
|
||||
keyword_analysis: {
|
||||
densities: result.visualization_data?.keyword_analysis?.densities || {},
|
||||
missing_keywords: result.visualization_data?.keyword_analysis?.missing_keywords || [],
|
||||
over_optimization: result.visualization_data?.keyword_analysis?.over_optimization || []
|
||||
},
|
||||
readability_metrics: result.visualization_data?.readability_metrics || {},
|
||||
content_stats: {
|
||||
word_count: result.visualization_data?.content_stats?.word_count || 0,
|
||||
sections: result.visualization_data?.content_stats?.sections || 0,
|
||||
paragraphs: result.visualization_data?.content_stats?.paragraphs || 0
|
||||
}
|
||||
},
|
||||
detailed_analysis: result.detailed_analysis || undefined,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
setAnalysisResult(convertedResult);
|
||||
|
||||
// Save to cache - use the same cacheKey that was used for checking
|
||||
try {
|
||||
// Use the same hash and cacheKey from the cache check section
|
||||
// This ensures consistency between cache check and save
|
||||
if (typeof window !== 'undefined' && cacheKey) {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(convertedResult));
|
||||
console.log('💾 SEO analysis cached', { cacheKey, overall_score: convertedResult.overall_score });
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('⚠️ Failed to cache SEO analysis', cacheError);
|
||||
}
|
||||
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// Notify parent that analysis is complete (fresh analysis)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(convertedResult);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('SEO analysis failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('SEOAnalysisModal: Detected subscription error, triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
setIsAnalyzing(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOAnalysisModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
|
||||
|
||||
// Precompute hash when modal opens and trigger cache check
|
||||
// Use a ref to prevent multiple simultaneous calls
|
||||
const hasRunAnalysisRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen && !hasRunAnalysisRef.current) {
|
||||
hasRunAnalysisRef.current = true;
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
// After hash is computed, check cache if we don't have analysis result yet
|
||||
if (!analysisResult) {
|
||||
// Small delay to ensure hash is set in state
|
||||
setTimeout(() => {
|
||||
runSEOAnalysis();
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
} else if (!isOpen) {
|
||||
// Reset hash and flag when modal closes
|
||||
setContentHash('');
|
||||
hasRunAnalysisRef.current = false;
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle, analysisResult, runSEOAnalysis]);
|
||||
|
||||
// Fallback: if modal opens and hash is already computed, check cache immediately
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult && contentHash && !hasRunAnalysisRef.current) {
|
||||
hasRunAnalysisRef.current = true;
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, contentHash, runSEOAnalysis]);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'success.main';
|
||||
if (score >= 60) return 'warning.main';
|
||||
return 'error.main';
|
||||
};
|
||||
|
||||
// Tooltip content for each metric
|
||||
const getMetricTooltip = (category: string) => {
|
||||
const tooltips = {
|
||||
structure: {
|
||||
title: "Content Structure Analysis",
|
||||
description: "Evaluates how well your content is organized and structured for both readers and search engines.",
|
||||
methodology: "Analyzes heading hierarchy (H1, H2, H3), paragraph length, section organization, and logical flow.",
|
||||
score_meaning: "Higher scores indicate better content organization, clear headings, and logical structure.",
|
||||
examples: "Good: Clear H1 title, logical H2 sections, short paragraphs. Poor: No headings, long walls of text."
|
||||
},
|
||||
keywords: {
|
||||
title: "Keyword Optimization Analysis",
|
||||
description: "Measures how effectively your target keywords are used throughout the content.",
|
||||
methodology: "Analyzes keyword density, distribution, placement in headings, and semantic keyword usage.",
|
||||
score_meaning: "Higher scores indicate optimal keyword usage without over-optimization.",
|
||||
examples: "Good: 1-3% keyword density, keywords in headings. Poor: Keyword stuffing or missing target keywords."
|
||||
},
|
||||
readability: {
|
||||
title: "Readability Assessment",
|
||||
description: "Evaluates how easy your content is to read and understand for your target audience.",
|
||||
methodology: "Uses Flesch Reading Ease, sentence length, word complexity, and paragraph structure.",
|
||||
score_meaning: "Higher scores indicate content that's easier to read and understand.",
|
||||
examples: "Good: Short sentences, simple words, clear paragraphs. Poor: Long complex sentences, jargon."
|
||||
},
|
||||
quality: {
|
||||
title: "Content Quality Evaluation",
|
||||
description: "Assesses the depth, value, and comprehensiveness of your content.",
|
||||
methodology: "Analyzes word count, content depth, information density, and topic coverage.",
|
||||
score_meaning: "Higher scores indicate more comprehensive and valuable content.",
|
||||
examples: "Good: Detailed explanations, examples, comprehensive coverage. Poor: Thin content, lack of detail."
|
||||
},
|
||||
headings: {
|
||||
title: "Heading Structure Analysis",
|
||||
description: "Evaluates the effectiveness of your heading hierarchy and organization.",
|
||||
methodology: "Analyzes heading distribution, hierarchy levels, keyword usage in headings, and logical flow.",
|
||||
score_meaning: "Higher scores indicate better heading structure and organization.",
|
||||
examples: "Good: Clear H1, logical H2/H3 progression. Poor: Missing headings, poor hierarchy."
|
||||
},
|
||||
ai_insights: {
|
||||
title: "AI-Powered Content Insights",
|
||||
description: "Advanced analysis of content engagement potential and user value.",
|
||||
methodology: "Uses AI to analyze content quality, engagement factors, and user value proposition.",
|
||||
score_meaning: "Higher scores indicate content that's more likely to engage and provide value to readers.",
|
||||
examples: "Good: Clear value proposition, engaging content, actionable insights. Poor: Generic content, low engagement potential."
|
||||
}
|
||||
};
|
||||
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: '90vh',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#f8fafc',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
color: '#0f172a'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Search sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
|
||||
SEO Analysis Results
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
setAnalysisResult(null);
|
||||
runSEOAnalysis(true);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
Comprehensive analysis of your blog content's SEO optimization
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isAnalyzing && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Refresh sx={{
|
||||
fontSize: 32,
|
||||
animation: 'spin 1s linear infinite',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{progressMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #4caf50, #8bc34a)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
||||
<Cancel sx={{ mr: 1 }} />
|
||||
{error}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{analysisResult && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Overall Score Section */}
|
||||
<OverallScoreCard
|
||||
overallScore={analysisResult.overall_score}
|
||||
overallGrade={analysisResult.analysis_summary.overall_grade}
|
||||
statusLabel={analysisResult.analysis_summary.status}
|
||||
categoryScores={analysisResult.category_scores}
|
||||
getMetricTooltip={getMetricTooltip}
|
||||
getScoreColor={getScoreColor}
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis Tabs */}
|
||||
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(e, newValue) => setTabValue(newValue)}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
'&.Mui-selected': {
|
||||
color: 'primary.main',
|
||||
fontWeight: 600
|
||||
}
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
background: 'linear-gradient(90deg, #4caf50, #8bc34a)',
|
||||
height: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab label="Recommendations" value="recommendations" />
|
||||
<Tab label="Keywords" value="keywords" />
|
||||
<Tab label="Readability" value="readability" />
|
||||
<Tab label="Structure" value="structure" />
|
||||
<Tab label="AI Insights" value="insights" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
{tabValue === 'recommendations' && (
|
||||
<Recommendations recommendations={analysisResult.actionable_recommendations} />
|
||||
)}
|
||||
|
||||
{tabValue === 'keywords' && (
|
||||
<KeywordAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
)}
|
||||
|
||||
{tabValue === 'readability' && (
|
||||
<ReadabilityAnalysis
|
||||
detailedAnalysis={analysisResult.detailed_analysis}
|
||||
visualizationData={analysisResult.visualization_data}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === 'structure' && (
|
||||
analysisResult ? (
|
||||
<StructureAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
|
||||
) : (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Loading structure analysis...
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
|
||||
{tabValue === 'insights' && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
AI-Powered Insights
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Content Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
|
||||
{analysisResult.analysis_summary.ai_summary}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Key Strengths
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Areas for Improvement
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{applyError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Cancel sx={{ mr: 1 }} />
|
||||
{applyError}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={async () => {
|
||||
if (!onApplyRecommendations) return;
|
||||
setApplyError(null);
|
||||
setIsApplying(true);
|
||||
try {
|
||||
await onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
// Increased delay to ensure sections are fully updated and phase stays in SEO
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 200);
|
||||
} catch (applyErr: any) {
|
||||
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}}
|
||||
disabled={!onApplyRecommendations || isApplying}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #45a049, #7cb342)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isApplying ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
Applying...
|
||||
</Box>
|
||||
) : (
|
||||
'Apply Recommendations'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
599
frontend/src/components/BlogWriter/SEOMetadataModal.tsx
Normal file
599
frontend/src/components/BlogWriter/SEOMetadataModal.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* SEO Metadata Modal Component
|
||||
*
|
||||
* Comprehensive SEO metadata generation and editing interface with:
|
||||
* - Tabbed interface for different metadata types
|
||||
* - Live preview of social media cards
|
||||
* - Character counters and validation
|
||||
* - Copy-to-clipboard functionality
|
||||
* - Integration with backend metadata generation
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
Check as CheckIcon,
|
||||
Preview as PreviewIcon,
|
||||
Search as SearchIcon,
|
||||
Share as ShareIcon,
|
||||
Code as CodeIcon,
|
||||
Tag as TagIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient, triggerSubscriptionError } from '../../api/client';
|
||||
|
||||
// Import metadata display components
|
||||
import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
|
||||
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
|
||||
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
|
||||
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
|
||||
import { subscribeImage } from '../../utils/imageBus';
|
||||
|
||||
interface SEOMetadataModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
blogContent: string;
|
||||
blogTitle: string;
|
||||
researchData: any;
|
||||
outline?: any[]; // Add outline structure
|
||||
seoAnalysis?: any; // Add SEO analysis results
|
||||
onMetadataGenerated: (metadata: any) => void;
|
||||
}
|
||||
|
||||
interface SEOMetadataResult {
|
||||
success: boolean;
|
||||
seo_title?: string;
|
||||
meta_description?: string;
|
||||
url_slug?: string;
|
||||
blog_tags?: string[];
|
||||
blog_categories?: string[];
|
||||
social_hashtags?: string[];
|
||||
open_graph?: any;
|
||||
twitter_card?: any;
|
||||
json_ld_schema?: any;
|
||||
canonical_url?: string;
|
||||
reading_time?: number;
|
||||
focus_keyword?: string;
|
||||
generated_at?: string;
|
||||
optimization_score?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache helper functions (similar to SEOAnalysisModal)
|
||||
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 getMetadataCacheKey(contentHash: string, title?: string): string {
|
||||
return `seo_metadata_cache:${contentHash}:${title || ''}`;
|
||||
}
|
||||
|
||||
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
outline,
|
||||
seoAnalysis,
|
||||
onMetadataGenerated
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first
|
||||
const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms
|
||||
const [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
|
||||
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
// Subscribe to image generation bus to auto-fill OG/Twitter image fields
|
||||
useEffect(() => {
|
||||
const unsub = subscribeImage(({ base64 }: { base64: string }) => {
|
||||
setEditableMetadata(prev => {
|
||||
const next = { ...(prev || metadataResult || {}) } as any;
|
||||
next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` };
|
||||
next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
}, [metadataResult]);
|
||||
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('🔍 SEOMetadataModal render:', {
|
||||
isOpen,
|
||||
blogContent: blogContent?.length,
|
||||
blogTitle,
|
||||
researchData: !!researchData
|
||||
});
|
||||
}
|
||||
}, [isOpen, blogContent?.length, blogTitle, researchData]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes (but keep result for next time)
|
||||
setError(null);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const generateMetadata = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
if (forceRefresh) {
|
||||
setMetadataResult(null);
|
||||
}
|
||||
|
||||
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
|
||||
|
||||
// Calculate content hash for caching - use existing hash if available
|
||||
let hash = contentHash;
|
||||
if (!hash) {
|
||||
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
// Update state for future use
|
||||
setContentHash(hash);
|
||||
}
|
||||
const cacheKey = getMetadataCacheKey(hash, blogTitle);
|
||||
console.log('🔍 Checking SEO metadata cache', { cacheKey, hasHash: !!hash, forceRefresh });
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && typeof window !== 'undefined') {
|
||||
const cached = window.localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOMetadataResult;
|
||||
// Validate cached data has required fields
|
||||
if (parsed && parsed.success !== undefined) {
|
||||
console.log('✅ Using cached SEO metadata', { cacheKey, success: parsed.success });
|
||||
setMetadataResult(parsed);
|
||||
setEditableMetadata(parsed);
|
||||
setIsGenerating(false);
|
||||
// Notify parent that metadata is available
|
||||
if (onMetadataGenerated) {
|
||||
onMetadataGenerated(parsed);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.warn('⚠️ Cached SEO metadata data is invalid, will fetch fresh metadata');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Failed to parse cached SEO metadata, will fetch fresh metadata', e);
|
||||
// Remove invalid cache entry
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No cached SEO metadata found, will fetch from API', { cacheKey });
|
||||
}
|
||||
} else {
|
||||
console.log('🔄 Force refresh requested, skipping cache check');
|
||||
}
|
||||
|
||||
// Make API call to generate metadata
|
||||
const response = await apiClient.post('/api/blog/seo/metadata', {
|
||||
content: blogContent,
|
||||
title: blogTitle,
|
||||
research_data: researchData,
|
||||
outline: outline || null,
|
||||
seo_analysis: seoAnalysis || null
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
console.log('✅ SEO metadata generation response:', result);
|
||||
|
||||
// Check if the response indicates a subscription error (even if HTTP status is 200)
|
||||
if (!result.success && result.error) {
|
||||
const errorMessage = result.error;
|
||||
// Check if error message indicates subscription limit (429/402)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error in response data', {
|
||||
error: errorMessage,
|
||||
data: result
|
||||
});
|
||||
|
||||
// Create a mock error object with subscription error data
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 429, // Treat as 429 for subscription error
|
||||
data: {
|
||||
error: errorMessage,
|
||||
message: result.message || errorMessage,
|
||||
provider: result.provider || 'unknown',
|
||||
usage_info: result.usage_info || {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// If not a subscription error, throw the error normally
|
||||
throw new Error(result.error || 'Metadata generation failed');
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(result));
|
||||
console.log('💾 SEO metadata cached');
|
||||
} catch (e) {
|
||||
console.warn('Failed to cache metadata:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setMetadataResult(result);
|
||||
setEditableMetadata(result);
|
||||
console.log('📊 Metadata result set:', result);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('❌ SEO metadata generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
const status = err?.response?.status;
|
||||
const errorMessage = err?.message || err?.response?.data?.error || '';
|
||||
|
||||
// Check HTTP status code first
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription') ||
|
||||
errorMessage.includes('429')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
|
||||
errorMessage,
|
||||
err
|
||||
});
|
||||
|
||||
// Create a mock error object with subscription error data
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 429,
|
||||
data: {
|
||||
error: errorMessage,
|
||||
message: errorMessage,
|
||||
provider: err?.response?.data?.provider || 'unknown',
|
||||
usage_info: err?.response?.data?.usage_info || {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
|
||||
|
||||
// Precompute hash when modal opens and trigger cache check
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
// After hash is computed, check cache if we don't have metadata result yet
|
||||
if (!metadataResult) {
|
||||
// Small delay to ensure hash is set in state
|
||||
setTimeout(() => {
|
||||
generateMetadata(false);
|
||||
}, 100);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Reset hash when modal closes
|
||||
setContentHash('');
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
|
||||
|
||||
// Fallback: if modal opens and hash is already computed, check cache immediately
|
||||
useEffect(() => {
|
||||
if (isOpen && !metadataResult && contentHash) {
|
||||
generateMetadata(false);
|
||||
}
|
||||
}, [isOpen, metadataResult, contentHash, generateMetadata]);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = async (text: string, itemId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedItems(prev => new Set([...prev, itemId]));
|
||||
setTimeout(() => {
|
||||
setCopiedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(itemId);
|
||||
return newSet;
|
||||
});
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataEdit = (field: string, value: any) => {
|
||||
if (editableMetadata) {
|
||||
setEditableMetadata(prev => ({
|
||||
...prev!,
|
||||
[field]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Apply Metadata button click
|
||||
*
|
||||
* This saves the generated/edited metadata to the parent component's state.
|
||||
* The metadata is then used when publishing to platforms:
|
||||
* - WordPress: Requires SEO metadata for proper post creation with SEO fields
|
||||
* - Wix: Currently doesn't require metadata, but could be added in future
|
||||
*
|
||||
* The metadata includes:
|
||||
* - SEO title, meta description, URL slug
|
||||
* - Blog tags, categories, focus keyword
|
||||
* - Open Graph tags (Facebook/LinkedIn)
|
||||
* - Twitter Card tags
|
||||
* - JSON-LD structured data (Schema.org Article)
|
||||
*
|
||||
* All of these will be passed to the platform's API when publishing.
|
||||
*/
|
||||
const handleApplyMetadata = () => {
|
||||
if (editableMetadata) {
|
||||
onMetadataGenerated(editableMetadata);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const getTabIcon = (tabValue: string) => {
|
||||
switch (tabValue) {
|
||||
case 'core': return <SearchIcon />;
|
||||
case 'social': return <ShareIcon />;
|
||||
case 'structured': return <CodeIcon />;
|
||||
case 'preview': return <PreviewIcon />;
|
||||
default: return <TagIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTabLabel = (tabValue: string) => {
|
||||
switch (tabValue) {
|
||||
case 'core': return 'Core SEO';
|
||||
case 'social': return 'Social Media';
|
||||
case 'structured': return 'Structured Data';
|
||||
case 'preview': return 'Preview';
|
||||
default: return 'Metadata';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: 3,
|
||||
minHeight: '80vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1,
|
||||
borderBottom: '1px solid rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TagIcon sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
SEO Metadata Generator
|
||||
</Typography>
|
||||
{metadataResult && (
|
||||
<Chip
|
||||
label={`${metadataResult.optimization_score || 0}% Optimized`}
|
||||
color={metadataResult.optimization_score && metadataResult.optimization_score >= 80 ? 'success' :
|
||||
metadataResult.optimization_score && metadataResult.optimization_score >= 60 ? 'warning' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{metadataResult && (
|
||||
<Tooltip title="Regenerate SEO metadata">
|
||||
<IconButton
|
||||
onClick={() => generateMetadata(true)}
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
{isGenerating && (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Generating SEO Metadata...
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Creating optimized titles, descriptions, and social media tags
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => generateMetadata(true)}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadataResult && (
|
||||
<Box>
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 3 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ minHeight: 48 }}
|
||||
>
|
||||
{['preview', 'core', 'social', 'structured'].map((tab) => (
|
||||
<Tab
|
||||
key={tab}
|
||||
value={tab}
|
||||
label={getTabLabel(tab)}
|
||||
icon={getTabIcon(tab)}
|
||||
iconPosition="start"
|
||||
sx={{ minHeight: 48, textTransform: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ p: 3 }}>
|
||||
{tabValue === 'core' && (
|
||||
<CoreMetadataTab
|
||||
metadata={editableMetadata || metadataResult}
|
||||
onMetadataEdit={handleMetadataEdit}
|
||||
onCopyToClipboard={handleCopyToClipboard}
|
||||
copiedItems={copiedItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === 'social' && (
|
||||
<SocialMediaTab
|
||||
metadata={editableMetadata || metadataResult}
|
||||
onMetadataEdit={handleMetadataEdit}
|
||||
onCopyToClipboard={handleCopyToClipboard}
|
||||
copiedItems={copiedItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === 'structured' && (
|
||||
<StructuredDataTab
|
||||
metadata={editableMetadata || metadataResult}
|
||||
onMetadataEdit={handleMetadataEdit}
|
||||
onCopyToClipboard={handleCopyToClipboard}
|
||||
copiedItems={copiedItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabValue === 'preview' && (
|
||||
<PreviewCard
|
||||
metadata={editableMetadata || metadataResult}
|
||||
blogTitle={blogTitle}
|
||||
previewTabValue={previewTabValue}
|
||||
onPreviewTabChange={setPreviewTabValue}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{metadataResult && (
|
||||
<DialogActions sx={{ p: 3, borderTop: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleApplyMetadata}
|
||||
startIcon={<CheckIcon />}
|
||||
sx={{ px: 3 }}
|
||||
>
|
||||
Apply Metadata
|
||||
</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
34
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
34
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { BlogSEOAnalyzeResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props {
|
||||
analysis?: BlogSEOAnalyzeResponse | null;
|
||||
}
|
||||
|
||||
const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
||||
if (!analysis) return null;
|
||||
return (
|
||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||
<div>Score: {analysis.overall_score}</div>
|
||||
{!!analysis.analysis_summary && (
|
||||
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
|
||||
Grade {analysis.analysis_summary.overall_grade} · {analysis.analysis_summary.status}
|
||||
</div>
|
||||
)}
|
||||
{!!analysis.actionable_recommendations?.length && (
|
||||
<ul>
|
||||
{analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
|
||||
<li key={index}>
|
||||
<strong>{rec.category}:</strong> {rec.recommendation}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOMiniPanel;
|
||||
|
||||
|
||||
122
frontend/src/components/BlogWriter/SectionGenerator.tsx
Normal file
122
frontend/src/components/BlogWriter/SectionGenerator.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface SectionGeneratorProps {
|
||||
outline: BlogOutlineSection[];
|
||||
research: BlogResearchResponse | null;
|
||||
genMode: 'draft' | 'polished';
|
||||
onSectionGenerated: (sectionId: string, markdown: string) => void;
|
||||
onContinuityRefresh: () => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
outline,
|
||||
research,
|
||||
genMode,
|
||||
onSectionGenerated,
|
||||
onContinuityRefresh,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
description: 'Generate content for a specific section using research and outline',
|
||||
parameters: [ { name: 'sectionId', type: 'string', description: 'Section ID', required: true } ],
|
||||
handler: async ({ sectionId }: { sectionId: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section, mode: genMode });
|
||||
if (res?.markdown) {
|
||||
onSectionGenerated(sectionId, res.markdown);
|
||||
onContinuityRefresh();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✍️ Content generated for "${section.heading}"! The section incorporates your research findings and primary keywords. You can now review the content, run SEO analysis, or generate more sections.`,
|
||||
section_summary: {
|
||||
heading: section.heading,
|
||||
content_length: res.markdown.length,
|
||||
primary_keywords: research?.keyword_analysis?.primary || []
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Section generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Content generation failed for "${section.heading}": ${errorMessage}. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
return { success: false, message: 'Failed to generate section content. Please try again.' };
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #f57c00',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#f57c00' }}>✍️ Generating Section Content</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section requirements and research data...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Incorporating primary keywords and SEO best practices...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Writing engaging content with proper structure...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring factual accuracy and readability...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateAllSections',
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
|
||||
onSectionGenerated(s.id, res.markdown);
|
||||
onContinuityRefresh();
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
render: ({ status }: any) => (status === 'inProgress' || status === 'executing') ? <div>Generating all sections…</div> : null
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default SectionGenerator;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user