feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
Issue #518 - Subscription not updating after checkout: - Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef) - Move checkout success polling from InitialRouteHandler into SubscriptionContext - Remove redundant polling code from InitialRouteHandler - Fix plan label: 'Free' instead of 'No Plan', proper capitalization - Add plan refresh button in UserBadge - Add 'View Costing Details' to UserBadge dropdown - Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI - Clean subscription=success URL param after verification Blog Writer WYSIWYG Editor enhancements: - Per-section preview toggle (view/edit icons) - Enhanced hover-based toolbar - Circular SVG progress stats bar with detailed tooltip - Research tool chips in stats bar footer - Per-section TTS with useTextToSpeech hook (browser native) - Full blog preview modal with print/PDF support - PlayAllTTSButton: sequential playback with progress bar - OnThisPageNav: floating sidebar with scroll tracking - Section data attributes for scroll anchoring GSC Brainstorm Topics feature: - Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations) - Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation - Frontend: gscBrainstorm.ts API client - Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect) - Frontend: useGSCBrainstorm hook (connect check + brainstorm call) - Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs) - Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay) - Wire BrainstormButton into ManualResearchForm and ResearchAction - Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
This commit is contained in:
@@ -107,7 +107,6 @@ const App: React.FC = () => {
|
||||
|
||||
// Initialize app - loading state will be managed by InitialRouteHandler
|
||||
useEffect(() => {
|
||||
// Remove manual health check - connection errors are handled by ErrorBoundary
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
|
||||
79
frontend/src/api/gscBrainstorm.ts
Normal file
79
frontend/src/api/gscBrainstorm.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ContentOpportunity {
|
||||
type: 'Content Optimization' | 'Content Enhancement';
|
||||
keyword: string;
|
||||
opportunity: string;
|
||||
potential_impact: 'High' | 'Medium';
|
||||
current_position: number;
|
||||
impressions: number;
|
||||
priority: 'High' | 'Medium';
|
||||
}
|
||||
|
||||
export interface KeywordGap {
|
||||
keyword: string;
|
||||
position: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
export interface AIRecommendations {
|
||||
immediate_opportunities: string[];
|
||||
content_strategy: string[];
|
||||
long_term_strategy: string[];
|
||||
}
|
||||
|
||||
export interface BrainstormSummary {
|
||||
site_url: string;
|
||||
date_range: { start: string; end: string };
|
||||
total_keywords_analyzed: number;
|
||||
total_impressions: number;
|
||||
total_clicks: number;
|
||||
avg_ctr: number;
|
||||
avg_position: number;
|
||||
keyword_distribution: {
|
||||
positions_1_3: number;
|
||||
positions_4_10: number;
|
||||
positions_11_20: number;
|
||||
positions_21_plus: number;
|
||||
};
|
||||
top_keywords: Array<{ keyword: string; impressions: number; position: number }>;
|
||||
top_pages: Array<{ page: string; clicks: number; impressions: number }>;
|
||||
}
|
||||
|
||||
export interface BrainstormResult {
|
||||
error?: string;
|
||||
content_opportunities: ContentOpportunity[];
|
||||
keyword_gaps: KeywordGap[];
|
||||
ai_recommendations: AIRecommendations | Record<string, never>;
|
||||
summary: BrainstormSummary | Record<string, never>;
|
||||
}
|
||||
|
||||
class GSCBrainstormAPI {
|
||||
private baseUrl = '/gsc';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||
this.getAuthToken = getToken;
|
||||
}
|
||||
|
||||
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}` },
|
||||
});
|
||||
}
|
||||
|
||||
async brainstorm(keywords: string, siteUrl?: string): Promise<BrainstormResult> {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/brainstorm`, {
|
||||
keywords,
|
||||
site_url: siteUrl || undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const gscBrainstormAPI = new GSCBrainstormAPI();
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
@@ -8,9 +8,6 @@ import { shouldSkipOnboarding, getDefaultLandingRoute, isFeatureOnlyMode, getSin
|
||||
import { restoreNavigationState } from '../../utils/navigationState';
|
||||
import ConnectionErrorPage from '../shared/ConnectionErrorPage';
|
||||
|
||||
const CHECKOUT_POLL_INTERVAL_MS = 2000;
|
||||
const CHECKOUT_POLL_MAX_ATTEMPTS = 10;
|
||||
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const navigateAndLog = (to: string) => {
|
||||
console.log(`InitialRouteHandler: Redirecting to ${to}`);
|
||||
@@ -27,11 +24,6 @@ const InitialRouteHandler: React.FC = () => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Post-checkout polling state
|
||||
const [checkoutPolling, setCheckoutPolling] = useState(false);
|
||||
const checkoutPollAttempts = useRef(0);
|
||||
// Track whether the initial subscription check has completed
|
||||
// Prevents premature routing decisions before we know the user's plan
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
@@ -79,48 +71,22 @@ const InitialRouteHandler: React.FC = () => {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// Handle post-checkout: when Stripe redirects back with ?subscription=success,
|
||||
// the webhook may not have processed yet. Poll until subscription becomes active.
|
||||
// Post-checkout: SubscriptionContext handles the verification polling.
|
||||
// InitialRouteHandler only needs to detect checkout success for routing decisions.
|
||||
// The actual subscription update now happens via verifyCheckout polling in SubscriptionContext.
|
||||
useEffect(() => {
|
||||
if (!isCheckoutSuccess) return;
|
||||
|
||||
// If subscription is already active after checkout, clean up URL
|
||||
if (subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
|
||||
// Webhook has processed — subscription is active, stop polling
|
||||
if (checkoutPolling) {
|
||||
console.log('InitialRouteHandler: Checkout success — subscription confirmed active, stopping poll');
|
||||
setCheckoutPolling(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling if webhook hasn't processed yet
|
||||
if (!checkoutPolling && checkoutPollAttempts.current === 0) {
|
||||
console.log('InitialRouteHandler: Checkout success — subscription not yet active, starting poll');
|
||||
setCheckoutPolling(true);
|
||||
}
|
||||
}, [isCheckoutSuccess, subscription, checkoutPolling]);
|
||||
|
||||
// Polling effect for post-checkout
|
||||
useEffect(() => {
|
||||
if (!checkoutPolling) return;
|
||||
|
||||
if (checkoutPollAttempts.current >= CHECKOUT_POLL_MAX_ATTEMPTS) {
|
||||
console.log('InitialRouteHandler: Checkout polling exhausted — proceeding with current state');
|
||||
setCheckoutPolling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
checkoutPollAttempts.current += 1;
|
||||
console.log(`InitialRouteHandler: Checkout poll attempt ${checkoutPollAttempts.current}/${CHECKOUT_POLL_MAX_ATTEMPTS}`);
|
||||
console.log('InitialRouteHandler: Checkout success — subscription confirmed:', subscription.plan);
|
||||
try {
|
||||
await checkSubscription();
|
||||
} catch (err) {
|
||||
console.error('InitialRouteHandler: Checkout poll check failed:', err);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} catch (e) {
|
||||
// Ignore URL cleanup errors
|
||||
}
|
||||
}, CHECKOUT_POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkoutPolling, checkSubscription]);
|
||||
}
|
||||
}, [isCheckoutSuccess, subscription]);
|
||||
|
||||
// Initialize onboarding when subscription is confirmed (but not on checkout success — let redirect happen)
|
||||
useEffect(() => {
|
||||
@@ -168,28 +134,6 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show polling spinner during post-checkout webhook wait
|
||||
if (checkoutPolling) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Activating your subscription...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
This may take a few seconds.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Post-checkout: subscription is now active (or poll exhausted)
|
||||
if (isCheckoutSuccess && subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
|
||||
// Restore navigation state (saved before Stripe redirect)
|
||||
@@ -232,7 +176,7 @@ const InitialRouteHandler: React.FC = () => {
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
checkSubscription().catch((err) => {
|
||||
checkSubscription(true).catch((err) => {
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAuth } from '@clerk/clerk-react';
|
||||
import { setAuthTokenGetter, setClerkSignOut } from '../../api/client';
|
||||
import { setMediaAuthTokenGetter } from '../../utils/fetchMediaBlobUrl';
|
||||
import { setBillingAuthTokenGetter } from '../../services/billingService';
|
||||
import { hallucinationDetectorService } from '../../services/hallucinationDetectorService';
|
||||
import { writingAssistantService } from '../../services/writingAssistantService';
|
||||
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||
@@ -35,6 +37,8 @@ const TokenInstaller: React.FC = () => {
|
||||
setAuthTokenGetter(tokenGetter);
|
||||
setBillingAuthTokenGetter(tokenGetter);
|
||||
setMediaAuthTokenGetter(tokenGetter);
|
||||
hallucinationDetectorService.setAuthTokenGetter(tokenGetter);
|
||||
writingAssistantService.setAuthTokenGetter(tokenGetter);
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
229
frontend/src/components/BlogWriter/BlogPreviewModal.tsx
Normal file
229
frontend/src/components/BlogWriter/BlogPreviewModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, IconButton, Typography, Box, Tooltip } from '@mui/material';
|
||||
import { Close as CloseIcon, Print as PrintIcon } from '@mui/icons-material';
|
||||
|
||||
interface BlogPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
introduction: string;
|
||||
sections: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
convertMarkdownToHTML: (md: string) => string;
|
||||
}
|
||||
|
||||
export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
introduction,
|
||||
sections,
|
||||
convertMarkdownToHTML,
|
||||
}) => {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
fullScreen
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
bgcolor: '#fafbfc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
bgcolor: 'white',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b' }}>
|
||||
👁️ Blog Preview
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Print or Save as PDF">
|
||||
<IconButton onClick={handlePrint} sx={{ color: '#4f46e5' }}>
|
||||
<PrintIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Return to Editing">
|
||||
<IconButton onClick={onClose} sx={{ color: '#64748b' }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<DialogContent
|
||||
sx={{
|
||||
px: { xs: 2, md: 4 },
|
||||
py: 4,
|
||||
maxWidth: '800px',
|
||||
mx: 'auto',
|
||||
bgcolor: 'white',
|
||||
borderRadius: 2,
|
||||
my: 2,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
>
|
||||
{/* Blog Title */}
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '2.5rem' },
|
||||
fontWeight: 800,
|
||||
color: '#1e293b',
|
||||
mb: 3,
|
||||
lineHeight: 1.2,
|
||||
fontFamily: 'Georgia, serif',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{/* Introduction */}
|
||||
{introduction && introduction.trim() && (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
pb: 4,
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '1.125rem',
|
||||
lineHeight: 1.8,
|
||||
color: '#475569',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(introduction) }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((section, index) => (
|
||||
<Box
|
||||
key={section.title || index}
|
||||
sx={{
|
||||
mb: 4,
|
||||
pb: 4,
|
||||
borderBottom: index < sections.length - 1 ? '1px solid #f1f5f9' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Section Title */}
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '1.5rem', md: '1.75rem' },
|
||||
fontWeight: 700,
|
||||
color: '#1e293b',
|
||||
mb: 2,
|
||||
mt: 3,
|
||||
fontFamily: 'Georgia, serif',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
pb: 1,
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</Typography>
|
||||
|
||||
{/* Section Content */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.8,
|
||||
color: '#334155',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(section.content) }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</DialogContent>
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
bgcolor: 'white',
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
{sections.length} sections • Preview Mode
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
|
||||
Press Ctrl+P to print
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
{/* Print Styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.MuiDialogContent-root, .MuiDialogContent-root * {
|
||||
visibility: visible;
|
||||
}
|
||||
.MuiDialogContent-root {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 20px !important;
|
||||
}
|
||||
/* Hide UI elements */
|
||||
.MuiDialog-paper > div:first-child,
|
||||
.MuiDialog-paper > div:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
/* Optimize for print */
|
||||
h1, h2, h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPreviewModal;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -36,6 +36,8 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
|
||||
const BlogWriter: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Add light theme class to body/html on mount, remove on unmount
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('blog-writer-page');
|
||||
@@ -76,6 +78,7 @@ const BlogWriter: React.FC = () => {
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
restoreAttempted,
|
||||
setResearch,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
@@ -203,6 +206,21 @@ const BlogWriter: React.FC = () => {
|
||||
// Store navigateToPhase in a ref for use in polling callbacks
|
||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||
|
||||
// Normalize section keys to match outline IDs when updating from API responses
|
||||
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
|
||||
if (outline && outline.length > 0 && Object.keys(newSections).length > 0) {
|
||||
const normalized: Record<string, string> = {};
|
||||
const values = Object.values(newSections);
|
||||
outline.forEach((s, idx) => {
|
||||
const id = String(s.id);
|
||||
normalized[id] = newSections[id] ?? values[idx] ?? '';
|
||||
});
|
||||
setSections(normalized);
|
||||
} else {
|
||||
setSections(newSections);
|
||||
}
|
||||
}, [outline, setSections]);
|
||||
|
||||
// Polling hooks - extracted to useBlogWriterPolling
|
||||
const {
|
||||
researchPolling,
|
||||
@@ -216,7 +234,7 @@ const BlogWriter: React.FC = () => {
|
||||
onResearchComplete: handleResearchComplete,
|
||||
onOutlineComplete: handleOutlineComplete,
|
||||
onOutlineError: handleOutlineError,
|
||||
onSectionsUpdate: setSections,
|
||||
onSectionsUpdate: handleSectionsUpdate,
|
||||
onContentConfirmed: () => {
|
||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||
setContentConfirmed(true);
|
||||
@@ -328,6 +346,14 @@ const BlogWriter: React.FC = () => {
|
||||
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
|
||||
setCurrentPhase]);
|
||||
|
||||
// Handle ?new=true query param from "New Blog" button in Asset Library
|
||||
React.useEffect(() => {
|
||||
if (searchParams.get('new') === 'true') {
|
||||
handleNewBlog();
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, [searchParams, handleNewBlog, setSearchParams]);
|
||||
|
||||
const handleMyBlogs = useCallback(() => {
|
||||
navigate('/asset-library?source_module=blog_writer&asset_type=text');
|
||||
}, [navigate]);
|
||||
@@ -532,6 +558,7 @@ const BlogWriter: React.FC = () => {
|
||||
currentPhase={currentPhase}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
restoreAttempted={restoreAttempted}
|
||||
/>
|
||||
|
||||
{research && (
|
||||
@@ -572,6 +599,8 @@ const BlogWriter: React.FC = () => {
|
||||
setShowOutlineModal(true);
|
||||
}}
|
||||
onContentGenerationStart={handleMediumGenerationStarted}
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import BlogWriterLanding from '../BlogWriterLanding';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
|
||||
@@ -8,36 +9,61 @@ interface BlogWriterLandingSectionProps {
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
onResearchComplete: (research: any) => void;
|
||||
restoreAttempted?: boolean;
|
||||
}
|
||||
|
||||
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
|
||||
|
||||
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
|
||||
research,
|
||||
copilotKitAvailable,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
restoreAttempted = false,
|
||||
}) => {
|
||||
// Only show landing/initial content when no research exists
|
||||
// Phase navigation header is always visible, so this is just the initial content
|
||||
if (!research) {
|
||||
// Show research form only when user explicitly navigated to research phase (clicked "Start Research")
|
||||
if (currentPhase === 'research') {
|
||||
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
|
||||
}
|
||||
|
||||
// Default: Always show landing page when no research exists
|
||||
// This ensures landing page is shown on initial load
|
||||
|
||||
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
|
||||
return (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (restoreAttempted) {
|
||||
return (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Navigate to research phase to show the research form
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="300px"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Restoring your work...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If research exists, don't show landing section (phase content will be shown instead)
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
import ManualOutlineButton from '../ManualOutlineButton';
|
||||
import ManualContentButton from '../ManualContentButton';
|
||||
import PublishContent from './PublishContent';
|
||||
|
||||
interface PhaseContentProps {
|
||||
currentPhase: string;
|
||||
@@ -40,6 +41,8 @@ interface PhaseContentProps {
|
||||
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
|
||||
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
|
||||
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
|
||||
buildFullMarkdown?: () => string;
|
||||
convertMarkdownToHTML?: (md: string) => string;
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
@@ -74,6 +77,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
onResearchComplete,
|
||||
onOutlineGenerationStart,
|
||||
onContentGenerationStart,
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
@@ -223,11 +228,14 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Publish Your Blog</h3>
|
||||
<p>Your blog is ready to publish!</p>
|
||||
</div>
|
||||
{currentPhase === 'publish' && buildFullMarkdown && convertMarkdownToHTML && (
|
||||
<PublishContent
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
seoMetadata={seoMetadata}
|
||||
seoAnalysis={seoAnalysis}
|
||||
blogTitle={selectedTitle ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../api/wordpress';
|
||||
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
|
||||
import WixConnectModal from './WixConnectModal';
|
||||
import { useWixPublish } from '../../../hooks/useWixPublish';
|
||||
|
||||
const saveCompleteBlogAsset = async (
|
||||
title: string,
|
||||
content: string,
|
||||
seoMetadata: BlogSEOMetadataResponse | null
|
||||
) => {
|
||||
try {
|
||||
await apiClient.post('/api/blog/save-complete-asset', {
|
||||
title,
|
||||
content,
|
||||
seo_title: seoMetadata?.seo_title,
|
||||
meta_description: seoMetadata?.meta_description,
|
||||
focus_keyword: seoMetadata?.focus_keyword,
|
||||
tags: seoMetadata?.blog_tags || [],
|
||||
categories: seoMetadata?.blog_categories || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save complete blog asset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
interface PublishContentProps {
|
||||
buildFullMarkdown: () => string;
|
||||
convertMarkdownToHTML: (md: string) => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
seoAnalysis?: any;
|
||||
blogTitle?: string;
|
||||
}
|
||||
|
||||
export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata,
|
||||
blogTitle,
|
||||
}) => {
|
||||
const {
|
||||
wixStatus,
|
||||
checkingWix,
|
||||
publishingWix,
|
||||
publishToWix,
|
||||
showWixConnectModal,
|
||||
setShowWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
} = useWixPublish();
|
||||
|
||||
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
|
||||
const [checkingWP, setCheckingWP] = useState(false);
|
||||
const [publishing, setPublishing] = useState<string | null>(null);
|
||||
const [publishResult, setPublishResult] = useState<{ platform: string; success: boolean; message: string; url?: string } | null>(null);
|
||||
const [copyDone, setCopyDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkWPStatus();
|
||||
}, []);
|
||||
|
||||
const checkWPStatus = async () => {
|
||||
setCheckingWP(true);
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch {
|
||||
setWordpressSites([]);
|
||||
} finally {
|
||||
setCheckingWP(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publishToWordPress = async () => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = convertMarkdownToHTML(md);
|
||||
setPublishing('wordpress');
|
||||
setPublishResult(null);
|
||||
|
||||
try {
|
||||
if (!seoMetadata) {
|
||||
setPublishResult({ platform: 'wordpress', success: false, message: 'Generate SEO metadata first before publishing.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSite = wordpressSites.find(s => s.is_active) || wordpressSites[0];
|
||||
if (!activeSite) {
|
||||
setPublishResult({ platform: 'wordpress', success: false, message: 'No WordPress sites connected. Go to Settings > Integrations to add one.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = seoMetadata.seo_title || md.match(/^#\s+(.+)$/m)?.[1] || 'Blog Post';
|
||||
const request: WordPressPublishRequest = {
|
||||
site_id: activeSite.id,
|
||||
title,
|
||||
content: html,
|
||||
excerpt: seoMetadata.meta_description || '',
|
||||
status: 'publish',
|
||||
meta_description: seoMetadata.meta_description || '',
|
||||
tags: seoMetadata.blog_tags || [],
|
||||
categories: seoMetadata.blog_categories || [],
|
||||
};
|
||||
|
||||
const result = await wordpressAPI.publishContent(request);
|
||||
if (result.success) {
|
||||
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
|
||||
} else {
|
||||
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPublishResult({ platform: 'wordpress', success: false, message: err?.response?.data?.detail || err.message || 'Publish failed' });
|
||||
} finally {
|
||||
setPublishing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishToWix = async () => {
|
||||
const md = buildFullMarkdown();
|
||||
setPublishResult(null);
|
||||
const result = await publishToWix(md, seoMetadata, blogTitle);
|
||||
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
|
||||
if (result.success) {
|
||||
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWixClick = () => {
|
||||
if (wixStatus?.connected) {
|
||||
handlePublishToWix();
|
||||
} else {
|
||||
setShowWixConnectModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = () => {
|
||||
navigator.clipboard.writeText(buildFullMarkdown());
|
||||
setCopyDone(true);
|
||||
setTimeout(() => setCopyDone(false), 2000);
|
||||
};
|
||||
|
||||
const handleCopyHTML = () => {
|
||||
navigator.clipboard.writeText(convertMarkdownToHTML(buildFullMarkdown()));
|
||||
setCopyDone(true);
|
||||
setTimeout(() => setCopyDone(false), 2000);
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: '#ffffff',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: 24,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
};
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#0f172a' }}>Publish Your Blog</h2>
|
||||
<p style={{ margin: '0 0 24px 0', color: '#64748b', fontSize: '0.9rem' }}>
|
||||
Your blog is ready to publish. Choose a platform below.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* WordPress card */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>WordPress</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{checkingWP ? 'Checking connection...' : wordpressSites.length > 0 ? `${wordpressSites.length} site(s) connected` : 'No sites connected'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={publishToWordPress}
|
||||
disabled={publishing === 'wordpress' || wordpressSites.length === 0}
|
||||
style={{
|
||||
...btnStyle,
|
||||
background: wordpressSites.length > 0 ? 'linear-gradient(135deg, #21759b, #1a6a8a)' : '#e2e8f0',
|
||||
color: wordpressSites.length > 0 ? '#fff' : '#94a3b8',
|
||||
cursor: wordpressSites.length > 0 && publishing !== 'wordpress' ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{publishing === 'wordpress' ? 'Publishing...' : 'Publish to WordPress'}
|
||||
</button>
|
||||
</div>
|
||||
{wordpressSites.length > 0 && wordpressSites[0] && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
|
||||
Target: {wordpressSites[0].site_name} ({wordpressSites[0].site_url})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wix card */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Wix</h3>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
{checkingWix ? 'Checking connection...' : wixStatus?.connected ? 'Connected' : 'Not connected'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWixClick}
|
||||
disabled={publishingWix}
|
||||
style={{
|
||||
...btnStyle,
|
||||
background: wixStatus?.connected ? 'linear-gradient(135deg, #0a6eff, #0052cc)' : '#6366f1',
|
||||
color: '#fff',
|
||||
cursor: !publishingWix ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{publishingWix ? 'Publishing...' : wixStatus?.connected ? 'Publish to Wix' : 'Connect Wix'}
|
||||
</button>
|
||||
</div>
|
||||
{wixStatus?.connected && wixStatus.site_info && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
|
||||
Site: {wixStatus.site_info.name || wixStatus.site_info.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export card */}
|
||||
<div style={cardStyle}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Export</h3>
|
||||
<p style={{ margin: '4px 0 12px 0', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
Copy your blog content for use elsewhere
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
|
||||
>
|
||||
{copyDone ? 'Copied!' : 'Copy Markdown'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyHTML}
|
||||
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
|
||||
>
|
||||
{copyDone ? 'Copied!' : 'Copy HTML'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publish result */}
|
||||
{publishResult && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: publishResult.success ? '#f0fdf4' : '#fef2f2',
|
||||
border: `1px solid ${publishResult.success ? '#86efac' : '#fecaca'}`,
|
||||
color: publishResult.success ? '#166534' : '#991b1b',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{publishResult.success ? '✅ Published!' : '❌ Publish failed'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem' }}>{publishResult.message}</div>
|
||||
{publishResult.url && (
|
||||
<a href={publishResult.url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', marginTop: 4, display: 'inline-block' }}>
|
||||
View published post
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WixConnectModal
|
||||
isOpen={showWixConnectModal}
|
||||
onClose={closeWixConnectModal}
|
||||
onConnectionSuccess={handleWixConnectionSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishContent;
|
||||
@@ -65,19 +65,34 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
console.log('Wix connected via URL param in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
||||
}
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
// Cross-tab: detect localStorage signal from OAuth in new tab
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'wix_connected' && e.newValue === 'true') {
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
return () => window.removeEventListener('storage', handler);
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
const handleConnectClick = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
@@ -90,16 +105,10 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
|
||||
// This ensures consistency between where OAuth starts and where callback happens
|
||||
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const isUsingNgrok = window.location.origin.includes('localhost') ||
|
||||
window.location.origin.includes('127.0.0.1') ||
|
||||
window.location.origin === NGROK_ORIGIN;
|
||||
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
|
||||
|
||||
// Build redirect URL with normalized origin
|
||||
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
|
||||
// Build redirect URL using the user's ACTUAL origin (where browser data lives).
|
||||
// Wix OAuth callback URI uses NGROK_ORIGIN (for Wix to reach us), but after OAuth
|
||||
// we must redirect back to the user's real origin so their localStorage data is available.
|
||||
const redirectUrl = `${window.location.origin}${currentPath}${currentHash}${currentSearch}`;
|
||||
|
||||
try {
|
||||
// Always override any existing redirect URL when connecting from Blog Writer
|
||||
@@ -107,8 +116,6 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
|
||||
redirectUrl,
|
||||
currentOrigin: window.location.origin,
|
||||
redirectOrigin,
|
||||
isUsingNgrok
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
|
||||
|
||||
@@ -47,26 +47,50 @@ export const useBlogWriterPolling = ({
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
|
||||
// Cache the generated content (shared utility)
|
||||
if (Object.keys(newSections).length > 0) {
|
||||
const sectionIds = Object.keys(newSections);
|
||||
blogWriterCache.cacheContent(newSections, sectionIds);
|
||||
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
|
||||
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
|
||||
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
|
||||
(async () => {
|
||||
try {
|
||||
const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi');
|
||||
const totalWords = result.sections.reduce(
|
||||
(sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length),
|
||||
0
|
||||
);
|
||||
await saveBlogToAssetLibrary({
|
||||
title: result.title || 'Untitled Blog',
|
||||
blogType: 'medium',
|
||||
wordCount: totalWords,
|
||||
sectionCount: result.sections?.length,
|
||||
model: result.model,
|
||||
generationTimeMs: result.generation_time_ms,
|
||||
});
|
||||
} catch (assetError) {
|
||||
console.error('[BlogWriter] Failed to save blog to asset library:', assetError);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply medium generation result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Medium generation failed:', err)
|
||||
onError: (err: any) => {
|
||||
console.error('Medium generation failed:', err);
|
||||
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
|
||||
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
|
||||
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
|
||||
} else if (errMsg.includes('no valid structured response')) {
|
||||
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Rewrite polling hook (used for blog rewrite operations)
|
||||
|
||||
@@ -168,7 +168,12 @@ export const usePhaseActionHandlers = ({
|
||||
} catch (error) {
|
||||
console.error('Content generation failed:', error);
|
||||
setIsMediumGenerationStarting(false);
|
||||
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
|
||||
alert('Your API balance is insufficient. Please top up your WaveSpeed account or switch to a different provider (e.g., set GPT_PROVIDER=google in your environment).');
|
||||
} else {
|
||||
alert(`Content generation failed: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For longer blogs, just confirm outline - user will use manual button
|
||||
|
||||
@@ -233,13 +233,18 @@ export const useSEOManager = ({
|
||||
try {
|
||||
const hash = await hashContent(`${title}\n${fullMarkdown}`);
|
||||
const cacheKey = getSeoCacheKey(hash, title);
|
||||
console.log('[SEOManager] SEO cache lookup', { cacheKey, hashLength: hash.length, titleLength: title.length, markdownLength: fullMarkdown.length });
|
||||
const cached = window.localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
|
||||
debug.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
|
||||
console.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
|
||||
setSeoAnalysis(parsed);
|
||||
} else {
|
||||
console.log('[SEOManager] Cached SEO data invalid', { hasScore: parsed && typeof parsed.overall_score === 'number' });
|
||||
}
|
||||
} else {
|
||||
console.log('[SEOManager] SEO cache miss', { cacheKey });
|
||||
}
|
||||
} catch (e) {
|
||||
debug.log('[SEOManager] Failed to restore cached SEO analysis', e);
|
||||
|
||||
280
frontend/src/components/BlogWriter/BrainstormButton.tsx
Normal file
280
frontend/src/components/BlogWriter/BrainstormButton.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useGSCBrainstorm } from '../../hooks/useGSCBrainstorm';
|
||||
import { GSCBrainstormModal } from './GSCBrainstormModal';
|
||||
|
||||
interface BrainstormButtonProps {
|
||||
keywords: string;
|
||||
onKeywordsChange: (val: string) => void;
|
||||
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
keywords,
|
||||
onKeywordsChange,
|
||||
onBrainstormResult,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showConnectOverlay, setShowConnectOverlay] = useState(false);
|
||||
const pendingBrainstormRef = useRef(false);
|
||||
const {
|
||||
gscConnected,
|
||||
isConnecting,
|
||||
connectError,
|
||||
isBrainstorming,
|
||||
brainstormError,
|
||||
contentOpportunities,
|
||||
keywordGaps,
|
||||
aiRecommendations,
|
||||
summary,
|
||||
connectGSC,
|
||||
brainstorm,
|
||||
reset,
|
||||
} = useGSCBrainstorm();
|
||||
|
||||
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
|
||||
const isVisible = wordCount >= 3;
|
||||
|
||||
// Auto-trigger brainstorm after GSC connection succeeds
|
||||
useEffect(() => {
|
||||
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
|
||||
pendingBrainstormRef.current = false;
|
||||
brainstorm(keywords).then((result) => {
|
||||
if (result && onBrainstormResult) {
|
||||
onBrainstormResult(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [gscConnected, isConnecting]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!gscConnected) {
|
||||
setShowConnectOverlay(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowModal(true);
|
||||
const result = await brainstorm(keywords);
|
||||
if (result && onBrainstormResult) {
|
||||
onBrainstormResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (suggestion: string) => {
|
||||
onKeywordsChange(suggestion);
|
||||
setShowModal(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleConnectGSC = async () => {
|
||||
pendingBrainstormRef.current = true;
|
||||
await connectGSC();
|
||||
};
|
||||
|
||||
const handleConnectSuccess = async () => {
|
||||
setShowConnectOverlay(false);
|
||||
setShowModal(true);
|
||||
const result = await brainstorm(keywords);
|
||||
if (result && onBrainstormResult) {
|
||||
onBrainstormResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectCancel = () => {
|
||||
setShowConnectOverlay(false);
|
||||
pendingBrainstormRef.current = false;
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isBrainstorming}
|
||||
title={
|
||||
wordCount < 3
|
||||
? 'Enter at least 3 words to enable brainstorming'
|
||||
: 'Brainstorm topics using your Google Search Console data'
|
||||
}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: disabled || isBrainstorming ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{isBrainstorming ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid #fff',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'brainstormSpin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes brainstormSpin { to { transform: rotate(360deg); } }`}</style>
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
'Brainstorm Topics'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<GSCBrainstormModal
|
||||
open={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
reset();
|
||||
}}
|
||||
contentOpportunities={contentOpportunities}
|
||||
keywordGaps={keywordGaps}
|
||||
aiRecommendations={aiRecommendations}
|
||||
summary={summary}
|
||||
error={brainstormError}
|
||||
isBrainstorming={isBrainstorming}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
/>
|
||||
|
||||
{showConnectOverlay && (
|
||||
<GSConnectOverlay
|
||||
isConnecting={isConnecting}
|
||||
connectError={connectError}
|
||||
gscConnected={gscConnected}
|
||||
onConnect={handleConnectGSC}
|
||||
onSuccess={handleConnectSuccess}
|
||||
onCancel={handleConnectCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* GSC Connection Overlay */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GSConnectOverlay: React.FC<{
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
gscConnected: boolean;
|
||||
onConnect: () => void;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
|
||||
// If connection just succeeded, auto-proceed
|
||||
if (gscConnected && !isConnecting) {
|
||||
onSuccess();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
maxWidth: '440px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '18px', color: '#333' }}>
|
||||
Connect Google Search Console
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px', fontSize: '14px', color: '#666', lineHeight: 1.5 }}>
|
||||
Brainstorm Topics uses your Google Search Console data to suggest blog topics
|
||||
based on what your audience is actually searching for.
|
||||
</p>
|
||||
|
||||
{connectError && (
|
||||
<p style={{ color: '#d32f2f', fontSize: '13px', margin: '0 0 16px' }}>{connectError}</p>
|
||||
)}
|
||||
|
||||
{isConnecting ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #e0e0e0',
|
||||
borderTopColor: '#4caf50',
|
||||
borderRadius: '50%',
|
||||
animation: 'gscSpin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes gscSpin { to { transform: rotate(360deg); } }`}</style>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>Opening Google sign-in...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onConnect}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Connect Google Search Console
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#888',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p style={{ fontSize: '12px', color: '#999', margin: '4px 0 0' }}>
|
||||
You'll be redirected to Google to authorize access. Your data stays private.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrainstormButton;
|
||||
File diff suppressed because it is too large
Load Diff
497
frontend/src/components/BlogWriter/GSCBrainstormModal.tsx
Normal file
497
frontend/src/components/BlogWriter/GSCBrainstormModal.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
AIRecommendations,
|
||||
BrainstormSummary,
|
||||
} from '../../api/gscBrainstorm';
|
||||
|
||||
interface GSCBrainstormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contentOpportunities: ContentOpportunity[];
|
||||
keywordGaps: KeywordGap[];
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
error: string | null;
|
||||
isBrainstorming: boolean;
|
||||
onSelectSuggestion: (keyword: string) => void;
|
||||
}
|
||||
|
||||
const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const;
|
||||
type TabKey = typeof tabLabels[number];
|
||||
|
||||
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
contentOpportunities,
|
||||
keywordGaps,
|
||||
aiRecommendations,
|
||||
summary,
|
||||
error,
|
||||
isBrainstorming,
|
||||
onSelectSuggestion,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hasNoData =
|
||||
!isBrainstorming &&
|
||||
!error &&
|
||||
contentOpportunities.length === 0 &&
|
||||
keywordGaps.length === 0 &&
|
||||
!aiRecommendations;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '720px',
|
||||
maxHeight: '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
|
||||
Brainstorm Topics with GSC Data
|
||||
</h3>
|
||||
{summary && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
|
||||
{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
||||
{summary.total_keywords_analyzed} keywords analyzed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: '#888',
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary metrics bar */}
|
||||
{summary && summary.total_keywords_analyzed > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
fontSize: '13px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{summary.total_impressions?.toLocaleString()}</strong> impressions
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.total_clicks?.toLocaleString()}</strong> clicks
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.avg_ctr}%</strong> avg CTR
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.avg_position}</strong> avg position
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isBrainstorming && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: '#1976d2',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 16px',
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
Analyzing your GSC data and generating topic suggestions...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isBrainstorming && (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
|
||||
{error}
|
||||
</p>
|
||||
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>
|
||||
Make sure your Google Search Console is connected and has data for the last 30 days.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data */}
|
||||
{hasNoData && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#888', margin: 0 }}>
|
||||
No brainstorming data available. Try different keywords or check your GSC connection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isBrainstorming && !error && !hasNoData && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{tabLabels.map((tab) => {
|
||||
const count =
|
||||
tab === 'Opportunities'
|
||||
? contentOpportunities.length
|
||||
: tab === 'Keyword Gaps'
|
||||
? keywordGaps.length
|
||||
: aiRecommendations
|
||||
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
|
||||
(aiRecommendations.content_strategy?.length ?? 0) +
|
||||
(aiRecommendations.long_term_strategy?.length ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
|
||||
background: activeTab === tab ? '#fff' : 'transparent',
|
||||
color: activeTab === tab ? '#1976d2' : '#666',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
{count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
|
||||
color: '#fff',
|
||||
borderRadius: '10px',
|
||||
padding: '1px 7px',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||
{activeTab === 'Opportunities' && (
|
||||
<OpportunitiesTab
|
||||
opportunities={contentOpportunities}
|
||||
onSelect={onSelectSuggestion}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'Keyword Gaps' && (
|
||||
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
|
||||
)}
|
||||
{activeTab === 'AI Recommendations' && (
|
||||
<AIRecommendationsTab
|
||||
recommendations={aiRecommendations}
|
||||
onSelect={onSelectSuggestion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const OpportunitiesTab: React.FC<{
|
||||
opportunities: ContentOpportunity[];
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ opportunities, onSelect }) => {
|
||||
if (opportunities.length === 0) {
|
||||
return <EmptyMessage message="No content opportunities found for this period." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{opportunities.map((opp, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={() => onSelect(opp.keyword)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
|
||||
{opp.keyword}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<Badge
|
||||
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
|
||||
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
|
||||
/>
|
||||
<Badge
|
||||
label={opp.priority}
|
||||
color={opp.priority === 'High' ? '#d32f2f' : '#666'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
|
||||
{opp.opportunity}
|
||||
</p>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{opp.impressions.toLocaleString()} impressions · Position {opp.current_position}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
||||
Click any keyword to use it as your research topic.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GapsTab: React.FC<{
|
||||
gaps: KeywordGap[];
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ gaps, onSelect }) => {
|
||||
if (gaps.length === 0) {
|
||||
return (
|
||||
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{gaps.map((gap, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={() => onSelect(gap.keyword)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
||||
>
|
||||
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
Position {gap.position} · {gap.impressions.toLocaleString()} impressions
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
||||
These keywords rank between positions 4-20. Writing targeted content could push them to page 1.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AIRecommendationsTab: React.FC<{
|
||||
recommendations: AIRecommendations | null;
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ recommendations, onSelect }) => {
|
||||
if (!recommendations) {
|
||||
return <EmptyMessage message="AI recommendations are not available right now." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<RecommendationSection
|
||||
title="Immediate Opportunities (0-30 days)"
|
||||
items={recommendations.immediate_opportunities}
|
||||
onSelect={onSelect}
|
||||
color="#1565c0"
|
||||
/>
|
||||
<RecommendationSection
|
||||
title="Content Strategy (1-3 months)"
|
||||
items={recommendations.content_strategy}
|
||||
onSelect={onSelect}
|
||||
color="#2e7d32"
|
||||
/>
|
||||
<RecommendationSection
|
||||
title="Long-Term Vision (3-12 months)"
|
||||
items={recommendations.long_term_strategy}
|
||||
onSelect={onSelect}
|
||||
color="#6a1b9a"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RecommendationSection: React.FC<{
|
||||
title: string;
|
||||
items: string[];
|
||||
onSelect: (keyword: string) => void;
|
||||
color: string;
|
||||
}> = ({ title, items, onSelect, color }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#444',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
|
||||
if (short) onSelect(short);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<p style={{ color: '#888', margin: 0 }}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default GSCBrainstormModal;
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BrainstormButton } from './BrainstormButton';
|
||||
|
||||
interface ManualResearchFormProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [blogLength, setBlogLength] = useState('1000');
|
||||
|
||||
const {
|
||||
startResearch,
|
||||
@@ -23,15 +24,15 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
} = useResearchSubmit({ onResearchComplete });
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const keywords = (keywordsRef.current?.value || '').trim();
|
||||
if (!keywords) {
|
||||
const trimmed = keywords.trim();
|
||||
if (!trimmed) {
|
||||
alert('Please enter keywords or a topic for research.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startResearch(keywords, blogLengthRef.current?.value || '1000');
|
||||
} catch (error) {
|
||||
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
await startResearch(trimmed, blogLength);
|
||||
} catch (err) {
|
||||
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,7 +50,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -67,8 +69,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
value={blogLength}
|
||||
onChange={(e) => setBlogLength(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -88,6 +90,11 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<BrainstormButton
|
||||
keywords={keywords}
|
||||
onKeywordsChange={setKeywords}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
@@ -122,5 +129,4 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualResearchForm;
|
||||
|
||||
export default ManualResearchForm;
|
||||
@@ -21,6 +21,12 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
|
||||
const getUserFriendlyMessage = (message: string): string => {
|
||||
// Map technical backend messages to user-friendly ones
|
||||
if (message.includes('insufficient_balance') || message.includes('balance_not_enough') || (message.includes('403') && message.includes('balance'))) {
|
||||
return '💳 Your API balance is insufficient. Please top up your account or switch providers in your settings.';
|
||||
}
|
||||
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
|
||||
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
|
||||
}
|
||||
if (message.includes('Starting outline generation')) {
|
||||
return '🧩 Starting to create your blog outline...';
|
||||
}
|
||||
|
||||
245
frontend/src/components/BlogWriter/PlayAllTTSButton.tsx
Normal file
245
frontend/src/components/BlogWriter/PlayAllTTSButton.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { IconButton, Tooltip, Box, Typography, LinearProgress } from '@mui/material';
|
||||
import { PlayArrow, Pause, Stop, VolumeUp } from '@mui/icons-material';
|
||||
import { useTextToSpeech } from '../../hooks/useTextToSpeech';
|
||||
|
||||
interface PlayAllTTSButtonProps {
|
||||
title: string;
|
||||
introduction: string;
|
||||
sections: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const PlayAllTTSButton: React.FC<PlayAllTTSButtonProps> = ({
|
||||
title,
|
||||
introduction,
|
||||
sections,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { speak, stop, isSpeaking, isSupported, isPaused, pause, resume } = useTextToSpeech();
|
||||
|
||||
const [isPlayingAll, setIsPlayingAll] = useState(false);
|
||||
const [currentSectionIndex, setCurrentSectionIndex] = useState(-1);
|
||||
const [isPausedAll, setIsPausedAll] = useState(false);
|
||||
const currentIndexRef = useRef(0);
|
||||
const isPlayingRef = useRef(false);
|
||||
const isWaitingForNextRef = useRef(false);
|
||||
|
||||
// Strip markdown for cleaner TTS
|
||||
const stripMarkdown = (md: string) => {
|
||||
return md
|
||||
.replace(/[#*_~`]/g, '')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Build all content as array of sections
|
||||
const allContent = React.useMemo(() => {
|
||||
const content: Array<{ label: string; text: string }> = [];
|
||||
|
||||
if (title) {
|
||||
content.push({ label: 'Title', text: stripMarkdown(title) });
|
||||
}
|
||||
if (introduction && introduction.trim()) {
|
||||
content.push({ label: 'Introduction', text: stripMarkdown(introduction) });
|
||||
}
|
||||
sections.forEach((section, index) => {
|
||||
if (section.content && section.content.trim()) {
|
||||
content.push({
|
||||
label: section.title || `Section ${index + 1}`,
|
||||
text: stripMarkdown(section.content)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return content;
|
||||
}, [title, introduction, sections]);
|
||||
|
||||
const totalSections = allContent.length;
|
||||
|
||||
// Play next section
|
||||
const playNext = useCallback(() => {
|
||||
if (currentIndexRef.current >= totalSections || !isPlayingRef.current) {
|
||||
// All done or stopped
|
||||
setIsPlayingAll(false);
|
||||
setCurrentSectionIndex(-1);
|
||||
currentIndexRef.current = 0;
|
||||
isPlayingRef.current = false;
|
||||
isWaitingForNextRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = allContent[currentIndexRef.current];
|
||||
if (!current || !current.text) {
|
||||
// Skip empty sections
|
||||
currentIndexRef.current += 1;
|
||||
playNext();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentSectionIndex(currentIndexRef.current);
|
||||
isWaitingForNextRef.current = true;
|
||||
|
||||
speak(current.text, { rate: 1 });
|
||||
}, [allContent, totalSections, speak]);
|
||||
|
||||
// Monitor speech completion
|
||||
useEffect(() => {
|
||||
if (!isPlayingAll || isPausedAll) return;
|
||||
|
||||
// If we were waiting for speech to end and now isSpeaking is false, play next
|
||||
if (isWaitingForNextRef.current && !isSpeaking) {
|
||||
isWaitingForNextRef.current = false;
|
||||
currentIndexRef.current += 1;
|
||||
|
||||
// Small delay before next section
|
||||
const timer = setTimeout(() => {
|
||||
if (isPlayingRef.current) {
|
||||
playNext();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isSpeaking, isPlayingAll, isPausedAll, playNext]);
|
||||
|
||||
// Start playing all
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (totalSections === 0) return;
|
||||
|
||||
stop();
|
||||
currentIndexRef.current = 0;
|
||||
isPlayingRef.current = true;
|
||||
setIsPlayingAll(true);
|
||||
setIsPausedAll(false);
|
||||
isWaitingForNextRef.current = false;
|
||||
playNext();
|
||||
}, [totalSections, stop, playNext]);
|
||||
|
||||
// Stop playing
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
isPlayingRef.current = false;
|
||||
setIsPlayingAll(false);
|
||||
setCurrentSectionIndex(-1);
|
||||
currentIndexRef.current = 0;
|
||||
setIsPausedAll(false);
|
||||
isWaitingForNextRef.current = false;
|
||||
}, [stop]);
|
||||
|
||||
// Pause/Resume
|
||||
const handlePauseResume = useCallback(() => {
|
||||
if (isPaused) {
|
||||
resume();
|
||||
setIsPausedAll(false);
|
||||
} else {
|
||||
pause();
|
||||
setIsPausedAll(true);
|
||||
}
|
||||
}, [isPaused, pause, resume]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isPlayingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isSupported || totalSections === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = totalSections > 0 && currentSectionIndex >= 0
|
||||
? ((currentSectionIndex + 1) / totalSections) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Play All Button */}
|
||||
{!isPlayingAll ? (
|
||||
<Tooltip title="Read entire blog aloud">
|
||||
<IconButton
|
||||
onClick={handlePlayAll}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#4f46e5',
|
||||
bgcolor: 'rgba(79, 70, 229, 0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(79, 70, 229, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VolumeUp sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{/* Pause/Resume Button */}
|
||||
<Tooltip title={isPausedAll ? 'Resume' : 'Pause'}>
|
||||
<IconButton
|
||||
onClick={handlePauseResume}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#d97706',
|
||||
bgcolor: 'rgba(217, 119, 6, 0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(217, 119, 6, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isPausedAll ? <PlayArrow sx={{ fontSize: 18 }} /> : <Pause sx={{ fontSize: 18 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Stop Button */}
|
||||
<Tooltip title="Stop reading">
|
||||
<IconButton
|
||||
onClick={handleStop}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#ef4444',
|
||||
bgcolor: 'rgba(239, 68, 68, 0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(239, 68, 68, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stop sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<Box sx={{ flex: 1, minWidth: 100, maxWidth: 150 }}>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b', display: 'block' }}>
|
||||
{currentSectionIndex >= 0 ? allContent[currentSectionIndex]?.label : ''}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: isPausedAll ? '#d97706' : '#4f46e5',
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
|
||||
{currentSectionIndex + 1} of {totalSections}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayAllTTSButton;
|
||||
@@ -3,8 +3,8 @@ import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../api/wordpress';
|
||||
import { validateAndRefreshWixTokens } from '../../utils/wixTokenUtils';
|
||||
import WixConnectModal from './BlogWriterUtils/WixConnectModal';
|
||||
import { useWixPublish } from '../../hooks/useWixPublish';
|
||||
|
||||
interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
@@ -34,57 +34,31 @@ const saveCompleteBlogAsset = async (
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface WixConnectionStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
permissions?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Publisher: React.FC<PublisherProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata
|
||||
}) => {
|
||||
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
|
||||
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
|
||||
const {
|
||||
publishToWix,
|
||||
showWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
} = useWixPublish();
|
||||
|
||||
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
|
||||
const [checkingWordPressStatus, setCheckingWordPressStatus] = useState(false);
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const [pendingWixPublish, setPendingWixPublish] = useState<(() => Promise<any>) | null>(null);
|
||||
|
||||
// Check platform connection statuses on component mount
|
||||
useEffect(() => {
|
||||
checkWixConnectionStatus();
|
||||
checkWordPressConnectionStatus();
|
||||
}, []);
|
||||
|
||||
const checkWixConnectionStatus = async () => {
|
||||
setCheckingWixStatus(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/wix/connection/status');
|
||||
setWixConnectionStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check Wix connection status:', error);
|
||||
setWixConnectionStatus({
|
||||
connected: false,
|
||||
has_permissions: false,
|
||||
error: 'Failed to check connection status'
|
||||
});
|
||||
} finally {
|
||||
setCheckingWixStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkWordPressConnectionStatus = async () => {
|
||||
setCheckingWordPressStatus(true);
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch (error: any) {
|
||||
// getStatus now handles 404 gracefully, so we should rarely hit this
|
||||
// Only log non-404 errors
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
}
|
||||
@@ -94,132 +68,6 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to publish to Wix
|
||||
const publishToWix = async (md: string, metadata: BlogSEOMetadataResponse | null, accessToken?: string): Promise<any> => {
|
||||
// Get access token if not provided
|
||||
if (!accessToken) {
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (!tokenResult.accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens not available. Please connect your Wix account.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
accessToken = tokenResult.accessToken;
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = metadata?.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract cover image URL, skip if base64 (Wix needs HTTP URL)
|
||||
let coverImageUrl: string | undefined = undefined;
|
||||
if (metadata?.open_graph?.image) {
|
||||
const imageUrl = metadata.open_graph.image;
|
||||
// Skip base64 images - Wix import_image needs HTTP/HTTPS URL
|
||||
if (typeof imageUrl === 'string' && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
|
||||
coverImageUrl = imageUrl;
|
||||
} else {
|
||||
console.warn('Skipping cover image - Wix requires HTTP/HTTPS URL, received:', imageUrl?.substring(0, 50));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Publish using same endpoint as WixTestPage
|
||||
// Backend will lookup/create category and tag IDs from names if needed
|
||||
const response = await apiClient.post('/api/wix/test/publish/real', {
|
||||
title: title,
|
||||
content: md, // Use markdown, backend converts it
|
||||
cover_image_url: coverImageUrl,
|
||||
// Pass category/tag names - backend will lookup existing or create new ones
|
||||
category_names: metadata?.blog_categories || [],
|
||||
tag_names: metadata?.blog_tags || [],
|
||||
publish: true,
|
||||
access_token: accessToken,
|
||||
member_id: undefined, // Let backend derive from token
|
||||
seo_metadata: metadata ? {
|
||||
seo_title: metadata.seo_title,
|
||||
meta_description: metadata.meta_description,
|
||||
focus_keyword: metadata.focus_keyword,
|
||||
blog_tags: metadata.blog_tags || [], // Used for SEO keywords
|
||||
social_hashtags: metadata.social_hashtags || [],
|
||||
open_graph: metadata.open_graph || {},
|
||||
twitter_card: metadata.twitter_card || {},
|
||||
canonical_url: metadata.canonical_url
|
||||
} : undefined
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: response.data.url,
|
||||
post_id: response.data.post_id,
|
||||
message: 'Blog post published successfully to Wix!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If auth error, token may be invalid - try refreshing or reconnect
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// Try to refresh one more time
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (tokenResult.needsReconnect) {
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, metadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens expired. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix'
|
||||
};
|
||||
}
|
||||
// If refresh worked, retry once
|
||||
if (tokenResult.accessToken) {
|
||||
return await publishToWix(md, metadata, tokenResult.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Wix connection success - retry publish
|
||||
const handleWixConnectionSuccess = async () => {
|
||||
if (pendingWixPublish) {
|
||||
const publishFn = pendingWixPublish;
|
||||
setPendingWixPublish(null);
|
||||
// Small delay to ensure tokens are saved in sessionStorage
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Retry the publish - this will be executed and return result
|
||||
// Note: The result won't show in CopilotKit UI since we're outside the action handler
|
||||
// But the publish will succeed and user will see their blog on Wix
|
||||
const result = await publishFn();
|
||||
console.log('Wix publish after connection:', result);
|
||||
// Optionally show a success notification
|
||||
if (result.success) {
|
||||
// Publish succeeded - user's blog is now on Wix
|
||||
console.log('Blog published to Wix successfully after connection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrying publish after connection:', error);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
// Enhanced publish action with Wix support
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
@@ -232,25 +80,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
const html = convertMarkdownToHTML(md);
|
||||
|
||||
if (platform === 'wix') {
|
||||
// Proactively validate and refresh tokens
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
|
||||
if (tokenResult.needsReconnect || !tokenResult.accessToken) {
|
||||
// Store the publish function to retry after connection
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, seoMetadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix account not connected. Please connect your Wix account to publish.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
|
||||
// We have a valid access token, proceed with publishing
|
||||
const wixResult = await publishToWix(md, seoMetadata, tokenResult.accessToken);
|
||||
const wixResult = await publishToWix(md, seoMetadata);
|
||||
if (wixResult.success) {
|
||||
saveCompleteBlogAsset(
|
||||
seoMetadata?.seo_title || 'Blog Post',
|
||||
@@ -260,7 +90,6 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
}
|
||||
return wixResult;
|
||||
} else if (platform === 'wordpress') {
|
||||
// WordPress publishing
|
||||
if (!seoMetadata) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -268,7 +97,6 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has connected WordPress sites
|
||||
if (wordpressSites.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -277,7 +105,6 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// Find first active site, or use first site if none are active
|
||||
const activeSite = wordpressSites.find(site => site.is_active) || wordpressSites[0];
|
||||
if (!activeSite) {
|
||||
return {
|
||||
@@ -287,16 +114,13 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = seoMetadata.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract excerpt from SEO metadata
|
||||
const excerpt = seoMetadata.meta_description || '';
|
||||
|
||||
// Build WordPress publish request
|
||||
const publishRequest: WordPressPublishRequest = {
|
||||
site_id: activeSite.id,
|
||||
title: title,
|
||||
@@ -395,10 +219,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
<>
|
||||
<WixConnectModal
|
||||
isOpen={showWixConnectModal}
|
||||
onClose={() => {
|
||||
setShowWixConnectModal(false);
|
||||
setPendingWixPublish(null);
|
||||
}}
|
||||
onClose={closeWixConnectModal}
|
||||
onConnectionSuccess={handleWixConnectionSuccess}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BrainstormButton } from './BrainstormButton';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
@@ -12,8 +13,8 @@ interface ResearchActionProps {
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
const [copilotKeywords, setCopilotKeywords] = useState('');
|
||||
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
|
||||
const hasNavigatedRef = useRef<boolean>(false);
|
||||
|
||||
const {
|
||||
@@ -111,7 +112,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
value={copilotKeywords}
|
||||
onChange={(e) => setCopilotKeywords(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
|
||||
/>
|
||||
@@ -121,8 +123,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
value={copilotBlogLength}
|
||||
onChange={(e) => setCopilotBlogLength(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
|
||||
>
|
||||
@@ -134,17 +136,22 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<BrainstormButton
|
||||
keywords={copilotKeywords}
|
||||
onKeywordsChange={setCopilotKeywords}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const keywords = (keywordsRef.current?.value || '').trim();
|
||||
const blogLength = blogLengthRef.current?.value || '1000';
|
||||
if (!keywords) return;
|
||||
try {
|
||||
await startResearch(keywords, blogLength);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
onClick={async () => {
|
||||
const kw = copilotKeywords.trim();
|
||||
const bl = copilotBlogLength;
|
||||
if (!kw) return;
|
||||
try {
|
||||
await startResearch(kw, bl);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
|
||||
@@ -166,6 +166,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [fromCache, setFromCache] = useState(false);
|
||||
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
@@ -213,6 +214,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
// Validate cached data has required fields
|
||||
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
|
||||
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
|
||||
setFromCache(true);
|
||||
setAnalysisResult(parsed);
|
||||
setIsAnalyzing(false);
|
||||
setProgress(100);
|
||||
@@ -322,6 +324,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
setFromCache(false);
|
||||
setAnalysisResult(convertedResult);
|
||||
|
||||
// Save to cache - use the same cacheKey that was used for checking
|
||||
@@ -482,6 +485,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
|
||||
SEO Analysis Results
|
||||
</Typography>
|
||||
{fromCache && analysisResult?.generated_at && (
|
||||
<Chip
|
||||
label={`Cached: ${new Date(analysisResult.generated_at).toLocaleString()}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
@@ -493,7 +504,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
runSEOAnalysis(true);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
{fromCache ? 'Re-Run Analysis' : 'Run Analysis'}
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
|
||||
@@ -212,14 +212,19 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
const result = response.data;
|
||||
console.log('✅ SEO metadata generation response:', result);
|
||||
|
||||
// Check if the response indicates a subscription error (even if HTTP status is 200)
|
||||
// Check if the response indicates a subscription/usage error (even if HTTP status is 200)
|
||||
if (!result.success && result.error) {
|
||||
const errorMessage = result.error;
|
||||
// Check if error message indicates subscription limit (429/402)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
const errorMessage = (result.error || '').toLowerCase();
|
||||
// Check if error message indicates subscription/balance limit
|
||||
if (errorMessage.includes('token limit') ||
|
||||
errorMessage.includes('balance') ||
|
||||
errorMessage.includes('insufficient') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription')) {
|
||||
errorMessage.includes('subscription') ||
|
||||
errorMessage.includes('403') ||
|
||||
errorMessage.includes('429') ||
|
||||
errorMessage.includes('quota')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error in response data', {
|
||||
error: errorMessage,
|
||||
data: result
|
||||
@@ -297,13 +302,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
} catch (err: any) {
|
||||
console.error('❌ SEO metadata generation failed:', err);
|
||||
|
||||
// Check if this is a subscription error (429/402) and trigger global subscription modal
|
||||
// Check if this is a subscription error (429/402/403) or balance/limit issue
|
||||
const status = err?.response?.status;
|
||||
const errorMessage = err?.message || err?.response?.data?.error || '';
|
||||
const rawError = err?.response?.data?.error || err?.response?.data?.message || '';
|
||||
const errorMessage = err?.message || rawError || '';
|
||||
const fullMessage = (errorMessage + ' ' + rawError + ' ' + JSON.stringify(err?.response?.data || {})).toLowerCase();
|
||||
|
||||
// Check HTTP status code first
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
|
||||
// Check HTTP status code for subscription/balance errors
|
||||
if (status === 429 || status === 402 || status === 403) {
|
||||
console.log('SEOMetadataModal: Detected usage/subscription error (HTTP status)', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
@@ -317,18 +324,21 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
|
||||
if (errorMessage.includes('Token limit') ||
|
||||
errorMessage.includes('limit would be exceeded') ||
|
||||
errorMessage.includes('usage limit') ||
|
||||
errorMessage.includes('subscription') ||
|
||||
errorMessage.includes('429')) {
|
||||
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
|
||||
errorMessage,
|
||||
// Check error message for balance/usage/subscription-related errors
|
||||
if (fullMessage.includes('balance') ||
|
||||
fullMessage.includes('insufficient') ||
|
||||
fullMessage.includes('limit would be exceeded') ||
|
||||
fullMessage.includes('usage limit') ||
|
||||
fullMessage.includes('token limit') ||
|
||||
fullMessage.includes('subscription') ||
|
||||
fullMessage.includes('429') ||
|
||||
fullMessage.includes('403') ||
|
||||
fullMessage.includes('quota')) {
|
||||
console.log('SEOMetadataModal: Detected usage/subscription error (message match)', {
|
||||
fullMessage,
|
||||
err
|
||||
});
|
||||
|
||||
// Create a mock error object with subscription error data
|
||||
const mockError = {
|
||||
response: {
|
||||
status: 429,
|
||||
@@ -343,7 +353,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from message)');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} else {
|
||||
@@ -353,7 +363,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider, TextField } from '@mui/material';
|
||||
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, TextField, Chip } from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
MoreHoriz as MoreHorizIcon,
|
||||
BarChart as BarChartIcon,
|
||||
Hub as HubIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
|
||||
import BlogSection from './BlogSection';
|
||||
import EditorSidebar from './EditorSidebar';
|
||||
import HoverMenu from './HoverMenu';
|
||||
import { useMarkdownProcessor } from '../../../hooks/useMarkdownProcessor';
|
||||
import BlogPreviewModal from '../BlogPreviewModal';
|
||||
import PlayAllTTSButton from '../PlayAllTTSButton';
|
||||
import OnThisPageNav from './OnThisPageNav';
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
@@ -31,6 +39,8 @@ interface BlogEditorProps {
|
||||
continuityRefresh?: number;
|
||||
flowAnalysisResults?: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
sourceMappingStats?: any;
|
||||
groundingInsights?: any;
|
||||
}
|
||||
|
||||
const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
@@ -45,7 +55,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
onSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
sectionImages = {}
|
||||
sectionImages = {},
|
||||
sourceMappingStats,
|
||||
groundingInsights
|
||||
}) => {
|
||||
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
|
||||
const [introduction, setIntroduction] = useState('');
|
||||
@@ -58,8 +70,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [editingIntro, setEditingIntro] = useState(false);
|
||||
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [currentSectionId, setCurrentSectionId] = useState<string | number | null>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const introInputRef = useRef<HTMLInputElement>(null);
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const totalWords = useMemo(() =>
|
||||
sections.reduce((sum, s) => sum + (s.content?.split(/\s+/).filter(Boolean).length || 0), 0),
|
||||
@@ -68,6 +83,55 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
|
||||
const readingTime = useMemo(() => Math.max(1, Math.ceil(totalWords / 200)), [totalWords]);
|
||||
|
||||
// Initialize markdown processor for preview functionality
|
||||
const sectionsForProcessor = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
sections.forEach(s => {
|
||||
result[s.id] = s.content || '';
|
||||
});
|
||||
return result;
|
||||
}, [sections]);
|
||||
|
||||
const { convertMarkdownToHTML } = useMarkdownProcessor(outline, sectionsForProcessor);
|
||||
|
||||
// Track current section based on scroll position
|
||||
useEffect(() => {
|
||||
const container = contentContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const sectionElements = container.querySelectorAll('[data-section-id]');
|
||||
let currentId: string | number | null = null;
|
||||
|
||||
sectionElements.forEach((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= 150) {
|
||||
currentId = el.getAttribute('data-section-id');
|
||||
}
|
||||
});
|
||||
|
||||
if (currentId) {
|
||||
setCurrentSectionId(currentId);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [sections]);
|
||||
|
||||
// Navigate to section
|
||||
const handleNavigateToSection = useCallback((sectionId: string | number) => {
|
||||
const container = contentContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const targetElement = container.querySelector(`[data-section-id="${sectionId}"]`);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (outline && outline.length > 0) {
|
||||
const initialSections = outline.map((section, index) => ({
|
||||
@@ -220,16 +284,26 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteSection = useCallback((sectionId: any) => {
|
||||
setSections(prev => prev.filter(s => s.id !== sectionId));
|
||||
if (onContentUpdate) {
|
||||
// Update parent with filtered sections
|
||||
setTimeout(() => {
|
||||
// Give React time to update state
|
||||
}, 0);
|
||||
}
|
||||
}, [onContentUpdate]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-8">
|
||||
{/* Main editor column */}
|
||||
<div className="flex-1 min-w-0 max-w-4xl">
|
||||
<div className="flex-1 min-w-0 max-w-4xl" ref={contentContainerRef}>
|
||||
<Paper elevation={0} className="bg-white p-8 md:p-10 rounded-xl border border-gray-200/60">
|
||||
{/* Title */}
|
||||
<div className="mb-6 pb-6 border-b border-gray-100">
|
||||
<div className="mb-6 pb-6 border-b border-gray-100" data-section-id="title">
|
||||
<div className="flex items-start gap-2 group">
|
||||
{editingTitle ? (
|
||||
<TextField
|
||||
@@ -257,6 +331,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
</h1>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mt-1 shrink-0 flex items-center gap-1">
|
||||
<Tooltip title="Preview full blog">
|
||||
<IconButton onClick={() => setShowPreviewModal(true)} size="small">
|
||||
<VisibilityIcon className="text-green-600" fontSize="small"/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Title actions">
|
||||
<IconButton size="small" onClick={(e) => setTitleMenuAnchor(e.currentTarget)}>
|
||||
<MoreHorizIcon className="text-gray-400" fontSize="small"/>
|
||||
@@ -278,7 +357,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Introduction */}
|
||||
<div className="mt-4 group/intro">
|
||||
<div className="mt-4 group/intro" data-section-id="intro">
|
||||
<div className="flex items-start gap-2">
|
||||
{editingIntro ? (
|
||||
<TextField
|
||||
@@ -333,45 +412,186 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
const imageId = imageIdByIndex || outlineSection?.id || section.id;
|
||||
const sectionImage = sectionImages?.[imageId] || null;
|
||||
return (
|
||||
<BlogSection
|
||||
key={section.id}
|
||||
{...section}
|
||||
onContentUpdate={onContentUpdate}
|
||||
expandedSections={expandedSections}
|
||||
toggleSectionExpansion={toggleSectionExpansion}
|
||||
refreshToken={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImage={sectionImage}
|
||||
/>
|
||||
<div key={section.id} data-section-id={section.id}>
|
||||
<BlogSection
|
||||
{...section}
|
||||
onContentUpdate={onContentUpdate}
|
||||
onDeleteSection={handleDeleteSection}
|
||||
expandedSections={expandedSections}
|
||||
toggleSectionExpansion={toggleSectionExpansion}
|
||||
refreshToken={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImage={sectionImage}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mt-8 pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{sections.length} {sections.length === 1 ? 'section' : 'sections'}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{totalWords.toLocaleString()} words</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{readingTime} min read</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100)}%` }}
|
||||
/>
|
||||
{/* Compact Stats Bar - Vertical Stack */}
|
||||
<Paper elevation={0} sx={{
|
||||
mt: 4,
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid #e2e8f0',
|
||||
bgcolor: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
|
||||
background: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
{/* Left: Stats */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Tooltip title="Total sections in your blog">
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#4f46e5', cursor: 'help' }}>
|
||||
📊 {sections.length} {sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span style={{ color: '#cbd5e1' }}>•</span>
|
||||
<Tooltip title="Total word count across all sections">
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#2563eb', cursor: 'help' }}>
|
||||
📝 {totalWords.toLocaleString()} words
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span style={{ color: '#cbd5e1' }}>•</span>
|
||||
<Tooltip title="Estimated reading time (200 words/minute)">
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#d97706', cursor: 'help' }}>
|
||||
⏱️ {readingTime} min read
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{totalWords > 0
|
||||
? `${Math.round(Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100))}%`
|
||||
: '0%'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Circular Progress + Play All TTS */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{(() => {
|
||||
const targetWords = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
|
||||
const progress = targetWords > 0 ? Math.min(100, Math.round((totalWords / targetWords) * 100)) : 0;
|
||||
const remaining = Math.max(0, targetWords - totalWords);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ padding: 4 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>Writing Progress</div>
|
||||
<div style={{ fontSize: '0.75rem' }}>
|
||||
✅ Completed: {totalWords.toLocaleString()} words<br/>
|
||||
🎯 Target: {targetWords.toLocaleString()} words<br/>
|
||||
📝 Remaining: {remaining.toLocaleString()} words<br/>
|
||||
📊 Progress: {progress}%
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<div style={{ position: 'relative', width: 56, height: 56, cursor: 'help' }}>
|
||||
<svg width="56" height="56" style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle
|
||||
cx="28"
|
||||
cy="28"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="28"
|
||||
cy="28"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke={progress >= 90 ? '#10b981' : '#6366f1'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 24}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 24 * (1 - progress / 100)}`}
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: progress >= 90 ? '#10b981' : '#6366f1',
|
||||
}}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Play All TTS Button */}
|
||||
<PlayAllTTSButton
|
||||
title={blogTitle}
|
||||
introduction={introduction}
|
||||
sections={sections.map(s => ({ title: s.title, content: s.content }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Tools - Compact Chips */}
|
||||
{(research || sourceMappingStats || groundingInsights) && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#64748b', marginRight: 4 }}>
|
||||
🔬 Research Tools:
|
||||
</span>
|
||||
{research && (
|
||||
<Chip
|
||||
icon={<BarChartIcon />}
|
||||
label="Keywords"
|
||||
size="small"
|
||||
onClick={() => console.log('Open keywords')}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#e0e7ff' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sourceMappingStats && (
|
||||
<Chip
|
||||
icon={<HubIcon />}
|
||||
label={`Sources (${sourceMappingStats.total_sources || 0})`}
|
||||
size="small"
|
||||
onClick={() => console.log('Open sources')}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#dbeafe' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{groundingInsights && (
|
||||
<Chip
|
||||
icon={<FactCheckIcon />}
|
||||
label="Grounding"
|
||||
size="small"
|
||||
onClick={() => console.log('Open grounding')}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: '0.7rem',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#fef3c7' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
@@ -384,6 +604,15 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On This Page Navigation */}
|
||||
<OnThisPageNav
|
||||
title={blogTitle}
|
||||
introduction={introduction}
|
||||
sections={sections}
|
||||
onNavigate={handleNavigateToSection}
|
||||
currentSectionId={currentSectionId}
|
||||
/>
|
||||
|
||||
{/* Title Selection Modal */}
|
||||
<Dialog open={showTitleModal} onClose={() => setShowTitleModal(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
@@ -505,6 +734,19 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
<Button onClick={() => setShowIntroductionModal(false)}>Cancel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Full Blog Preview Modal */}
|
||||
<BlogPreviewModal
|
||||
isOpen={showPreviewModal}
|
||||
onClose={() => setShowPreviewModal(false)}
|
||||
title={blogTitle}
|
||||
introduction={introduction}
|
||||
sections={sections.map(s => ({
|
||||
title: s.title,
|
||||
content: s.content,
|
||||
}))}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -6,21 +6,22 @@ import {
|
||||
TextField,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Divider
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
DeleteOutline as DeleteOutlineIcon,
|
||||
FileCopyOutlined as FileCopyOutlinedIcon,
|
||||
Link as LinkIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
MoreHoriz as MoreHorizIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
|
||||
import HoverMenu from './HoverMenu';
|
||||
import { blogWriterApi } from '../../../services/blogWriterApi';
|
||||
import { TextToSpeechButton } from '../../shared/TextToSpeechButton';
|
||||
|
||||
interface BlogSectionProps {
|
||||
id: any;
|
||||
@@ -36,11 +37,13 @@ interface BlogSectionProps {
|
||||
targetWords: number;
|
||||
};
|
||||
onContentUpdate?: (sections: any[]) => void;
|
||||
onDeleteSection?: (sectionId: any) => void;
|
||||
expandedSections: Set<any>;
|
||||
toggleSectionExpansion: (sectionId: any) => void;
|
||||
refreshToken?: number;
|
||||
flowAnalysisResults?: any;
|
||||
sectionImage?: string;
|
||||
convertMarkdownToHTML?: (md: string) => string;
|
||||
}
|
||||
|
||||
const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
@@ -50,13 +53,16 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
sources,
|
||||
outlineData,
|
||||
onContentUpdate,
|
||||
onDeleteSection,
|
||||
expandedSections,
|
||||
toggleSectionExpansion,
|
||||
refreshToken,
|
||||
flowAnalysisResults,
|
||||
sectionImage
|
||||
sectionImage,
|
||||
convertMarkdownToHTML
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [sectionTitle, setSectionTitle] = useState(title);
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
@@ -224,26 +230,187 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Section Toolbar - Shows on hover, positioned next to title */}
|
||||
<div
|
||||
className="section-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isHovered ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Preview/Edit Toggle */}
|
||||
{convertMarkdownToHTML && (
|
||||
<Tooltip title={isPreviewing ? 'Edit content' : 'Preview content'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsPreviewing(!isPreviewing)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: isPreviewing ? '#4f46e5' : 'white',
|
||||
color: isPreviewing ? 'white' : '#475569',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: isPreviewing ? '#4338ca' : '#f8fafc',
|
||||
borderColor: isPreviewing ? '#4338ca' : '#cbd5e1',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isPreviewing ? <EditIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Copy Button */}
|
||||
<Tooltip title="Copy section">
|
||||
<IconButton size="small" sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'white',
|
||||
color: '#64748b',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: '#f8fafc',
|
||||
borderColor: '#cbd5e1',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* More Actions */}
|
||||
<Tooltip title="Section actions">
|
||||
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'white',
|
||||
color: '#64748b',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: '#f8fafc',
|
||||
borderColor: '#cbd5e1',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<MoreHorizIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete Button */}
|
||||
<Tooltip title="Delete section">
|
||||
<IconButton size="small" onClick={() => {
|
||||
if (window.confirm(`Are you sure you want to delete "${sectionTitle}"? This cannot be undone.`)) {
|
||||
onDeleteSection?.(id);
|
||||
}
|
||||
}} sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: 'white',
|
||||
color: '#ef4444',
|
||||
border: '1px solid #fecaca',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
'&:hover': {
|
||||
bgcolor: '#fef2f2',
|
||||
borderColor: '#fca5a5',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Text-to-Speech Button */}
|
||||
{content && content.trim().length > 0 && (
|
||||
<TextToSpeechButton
|
||||
text={content}
|
||||
size="small"
|
||||
showSettings={false}
|
||||
disabled={isPreviewing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sectionImage && (
|
||||
{sectionImage && (
|
||||
<div className="mb-4">
|
||||
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImage}`}
|
||||
alt={`Cover image for ${sectionTitle}`}
|
||||
src={sectionImage.startsWith('http') || sectionImage.startsWith('/api/') ? sectionImage : `data:image/png;base64,${sectionImage}`}
|
||||
alt={`Image for ${sectionTitle}`}
|
||||
className="w-full h-auto max-h-96 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
)}
|
||||
|
||||
{isGenerating ? (
|
||||
<div className="flex items-center gap-3 p-6 bg-indigo-50/50 rounded-lg border border-indigo-100/50 mb-3">
|
||||
<CircularProgress size={20} className="text-indigo-400" />
|
||||
<span className="text-sm text-indigo-600 font-medium">Generating content...</span>
|
||||
</div>
|
||||
) : isPreviewing && convertMarkdownToHTML ? (
|
||||
// Preview Mode
|
||||
<div className="relative">
|
||||
<Box
|
||||
className="preview-content"
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: '#fafbfc',
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e5e7eb',
|
||||
fontFamily: 'Georgia, serif',
|
||||
lineHeight: 1.8,
|
||||
color: '#1f2937',
|
||||
'& h1, & h2, & h3': { color: '#111827', mt: 2, mb: 1 },
|
||||
'& h2': { fontSize: '1.5rem', fontWeight: 600, borderBottom: '1px solid #e5e7eb', pb: 1 },
|
||||
'& p': { mb: 1.5 },
|
||||
'& strong': { fontWeight: 600 },
|
||||
'& em': { fontStyle: 'italic' },
|
||||
'& a': { color: '#4f46e5', textDecoration: 'underline' },
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid #e5e7eb',
|
||||
pl: 2,
|
||||
py: 1,
|
||||
color: '#6b7280',
|
||||
fontStyle: 'italic',
|
||||
bgcolor: '#f9fafb',
|
||||
},
|
||||
'& code': {
|
||||
bgcolor: '#f1f5f9',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 0.25,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.9em',
|
||||
},
|
||||
'& ul, & ol': { pl: 2, mb: 1.5 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& hr': { borderColor: '#e5e7eb', my: 2 },
|
||||
'& img': { maxWidth: '100%', height: 'auto', borderRadius: 1 },
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(content) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Edit Mode
|
||||
<div className="relative">
|
||||
<TextField
|
||||
multiline
|
||||
@@ -332,36 +499,40 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{/* Bottom word count - compact */}
|
||||
<div className="flex items-center justify-between mt-2" style={{ opacity: isHovered || isFocused ? 1 : 0, transition: 'opacity 0.2s' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">{wordCount_} words</span>
|
||||
<span className="text-xs" style={{ fontWeight: 600, color: '#94a3b8' }}>
|
||||
📝 {wordCount_} words
|
||||
</span>
|
||||
{outlineData?.targetWords && outlineData.targetWords > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 text-xs">/</span>
|
||||
<span className="text-xs text-gray-400">{outlineData.targetWords} target</span>
|
||||
<span className="text-xs" style={{
|
||||
fontWeight: 600,
|
||||
color: wordCount_ >= outlineData.targetWords * 0.9 ? '#10b981' : '#94a3b8',
|
||||
}}>
|
||||
{outlineData.targetWords} target
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s' }}>
|
||||
<div className="flex items-center gap-1">
|
||||
{outlineData && (
|
||||
<Tooltip title={expandedSections.has(id) ? 'Hide outline info' : 'Show outline info'}>
|
||||
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{ width: 28, height: 28 }}>
|
||||
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
bgcolor: 'transparent',
|
||||
color: '#64748b',
|
||||
'&:hover': {
|
||||
bgcolor: '#f1f5f9',
|
||||
},
|
||||
}}>
|
||||
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 14 }} /> : <ExpandMoreIcon sx={{ fontSize: 14 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Section actions">
|
||||
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{ width: 28, height: 28 }}>
|
||||
<MoreHorizIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" sx={{ width: 28, height: 28 }}>
|
||||
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" sx={{ width: 28, height: 28 }}>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 16, color: '#9ca3af' }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import { chartApi, ChartGenerateResponse } from '../../../services/chartApi';
|
||||
import TextSelectionMenu from './TextSelectionMenu';
|
||||
import ChartGeneratorModal from '../../Chart/ChartGeneratorModal';
|
||||
import LinkSearchModal from '../../Link/LinkSearchModal';
|
||||
import useSmartTypingAssist from './SmartTypingAssist';
|
||||
// import { debug } from '../../../utils/debug'; // Unused import
|
||||
|
||||
@@ -17,6 +20,11 @@ const useBlogTextSelectionHandler = (
|
||||
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
|
||||
const [isFactChecking, setIsFactChecking] = useState(false);
|
||||
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||
const [chartModalOpen, setChartModalOpen] = useState(false);
|
||||
const [chartModalText, setChartModalText] = useState('');
|
||||
const [chartResult, setChartResult] = useState<(ChartGenerateResponse & { sectionId?: string }) | null>(null);
|
||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
||||
const [linkModalText, setLinkModalText] = useState('');
|
||||
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Use the extracted smart typing assist hook
|
||||
@@ -108,6 +116,38 @@ const useBlogTextSelectionHandler = (
|
||||
setFactCheckResults(null);
|
||||
};
|
||||
|
||||
// Chart generation handler
|
||||
const handleGenerateChart = (text: string) => {
|
||||
setChartModalText(text);
|
||||
setChartModalOpen(true);
|
||||
setSelectionMenu(null);
|
||||
};
|
||||
|
||||
const handleChartGenerated = (result: ChartGenerateResponse & { sectionId?: string }) => {
|
||||
setChartResult(result);
|
||||
setChartModalOpen(false);
|
||||
};
|
||||
|
||||
const handleFindLinks = (text: string) => {
|
||||
setLinkModalText(text);
|
||||
setLinkModalOpen(true);
|
||||
setSelectionMenu(null);
|
||||
};
|
||||
|
||||
const handleLinkRewordAccept = (rewordedText: string, sectionId?: string) => {
|
||||
if (onTextReplace && linkModalText) {
|
||||
onTextReplace(linkModalText, rewordedText, 'reword-with-links');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
|
||||
detail: {
|
||||
originalText: linkModalText,
|
||||
editedText: rewordedText,
|
||||
editType: 'reword-with-links'
|
||||
}
|
||||
}));
|
||||
setLinkModalOpen(false);
|
||||
};
|
||||
|
||||
// Blog-specific quick edit functionality for selected text
|
||||
const handleQuickEdit = (editType: string, selectedText: string) => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
|
||||
@@ -273,7 +313,8 @@ const useBlogTextSelectionHandler = (
|
||||
...smartTypingAssist,
|
||||
// Render the selection menu and fact-check components
|
||||
renderSelectionMenu: () => (
|
||||
<TextSelectionMenu
|
||||
<>
|
||||
<TextSelectionMenu
|
||||
selectionMenu={selectionMenu}
|
||||
factCheckResults={factCheckResults}
|
||||
isFactChecking={isFactChecking}
|
||||
@@ -284,6 +325,8 @@ const useBlogTextSelectionHandler = (
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onGenerateChart={handleGenerateChart}
|
||||
onFindLinks={handleFindLinks}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
@@ -292,6 +335,25 @@ const useBlogTextSelectionHandler = (
|
||||
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||
/>
|
||||
{chartModalOpen && (
|
||||
<ChartGeneratorModal
|
||||
isOpen={chartModalOpen}
|
||||
onClose={() => setChartModalOpen(false)}
|
||||
defaultText={chartModalText}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
/>
|
||||
)}
|
||||
{linkModalOpen && (
|
||||
<LinkSearchModal
|
||||
isOpen={linkModalOpen}
|
||||
onClose={() => setLinkModalOpen(false)}
|
||||
sectionHeading=""
|
||||
sectionText={linkModalText}
|
||||
selectedText={linkModalText}
|
||||
onRewordAccept={handleLinkRewordAccept}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,30 +13,10 @@ interface EditorSidebarProps {
|
||||
|
||||
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
|
||||
const wordTarget = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
|
||||
const progress = wordTarget > 0 ? Math.min(100, Math.round((totalWords / wordTarget) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Paper elevation={0} className="p-5 rounded-xl border border-gray-200/60 bg-white">
|
||||
{/* Progress ring */}
|
||||
<div className="text-center mb-5">
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg className="w-20 h-20 -rotate-90">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="#f3f4f6" strokeWidth="4" />
|
||||
<circle
|
||||
cx="40" cy="40" r="34"
|
||||
fill="none" stroke="#4f46e5" strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 34}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 34 * (1 - progress / 100)}`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-lg font-bold text-gray-700">{progress}%</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">content complete</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2 mb-5">
|
||||
<div className="flex justify-between text-sm">
|
||||
|
||||
167
frontend/src/components/BlogWriter/WYSIWYG/OnThisPageNav.tsx
Normal file
167
frontend/src/components/BlogWriter/WYSIWYG/OnThisPageNav.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Paper, Typography, Box, Tooltip } from '@mui/material';
|
||||
import { Navigation as NavigationIcon } from '@mui/icons-material';
|
||||
|
||||
interface Section {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface OnThisPageNavProps {
|
||||
title: string;
|
||||
introduction: string;
|
||||
sections: Section[];
|
||||
onNavigate: (sectionId: string | number) => void;
|
||||
currentSectionId?: string | number | null;
|
||||
}
|
||||
|
||||
const OnThisPageNav: React.FC<OnThisPageNavProps> = ({
|
||||
title,
|
||||
introduction,
|
||||
sections,
|
||||
onNavigate,
|
||||
currentSectionId,
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const allItems = React.useMemo(() => {
|
||||
const items: Array<{ id: string | number; label: string; type: 'title' | 'intro' | 'section' }> = [];
|
||||
|
||||
if (title) {
|
||||
items.push({ id: 'title', label: title, type: 'title' });
|
||||
}
|
||||
if (introduction && introduction.trim()) {
|
||||
items.push({ id: 'intro', label: 'Introduction', type: 'intro' });
|
||||
}
|
||||
sections.forEach((section, index) => {
|
||||
items.push({
|
||||
id: section.id,
|
||||
label: section.title || `Section ${index + 1}`,
|
||||
type: 'section'
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [title, introduction, sections]);
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
right: isCollapsed ? 0 : 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1000,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: isCollapsed ? '12px 0 0 12px' : '12px 0 0 12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRight: 'none',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
maxWidth: isCollapsed ? 40 : 240,
|
||||
overflow: 'hidden',
|
||||
boxShadow: isHovered ? '0 8px 24px rgba(0,0,0,0.12)' : '0 2px 8px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Toggle Button */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isCollapsed ? 'center' : 'space-between',
|
||||
p: 1,
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
bgcolor: '#f8fafc',
|
||||
}}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#4f46e5', fontSize: '0.7rem' }}>
|
||||
On This Page
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip title={isCollapsed ? 'Expand' : 'Collapse'}>
|
||||
<NavigationIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: '#4f46e5',
|
||||
transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Navigation Items */}
|
||||
{!isCollapsed && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: 4,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: '#cbd5e1',
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{allItems.map((item, index) => {
|
||||
const isActive = currentSectionId === item.id;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`${item.type}-${item.id}`}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
px: 1.5,
|
||||
mb: 0.5,
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
borderLeft: isActive ? '3px solid #4f46e5' : '3px solid transparent',
|
||||
bgcolor: isActive ? 'rgba(79, 70, 229, 0.08)' : 'transparent',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(79, 70, 229, 0.05)',
|
||||
borderLeftColor: '#6366f1',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? '#4f46e5' : '#64748b',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.type === 'title' && '📝 '}
|
||||
{item.type === 'intro' && '📖 '}
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnThisPageNav;
|
||||
@@ -36,6 +36,8 @@ interface TextSelectionMenuProps {
|
||||
suggestionIndex: number;
|
||||
showContinueWritingPrompt: boolean;
|
||||
onCheckFacts: (text: string) => void;
|
||||
onGenerateChart: (text: string) => void;
|
||||
onFindLinks: (text: string) => void;
|
||||
onCloseFactCheckResults: () => void;
|
||||
onQuickEdit: (editType: string, selectedText: string) => void;
|
||||
onAcceptSuggestion: () => void;
|
||||
@@ -56,6 +58,8 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
suggestionIndex,
|
||||
showContinueWritingPrompt,
|
||||
onCheckFacts,
|
||||
onGenerateChart,
|
||||
onFindLinks,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
@@ -147,6 +151,72 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Generate Chart Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onGenerateChart(selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(124, 58, 237, 0.2)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.4)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.35)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)';
|
||||
}}
|
||||
>
|
||||
📊 Generate Chart
|
||||
</button>
|
||||
|
||||
{/* Find Links Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindLinks(selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.2)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.35)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.2)';
|
||||
}}
|
||||
>
|
||||
🔗 Find Links
|
||||
</button>
|
||||
|
||||
{/* Quick Edit Options */}
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
|
||||
432
frontend/src/components/Chart/ChartGeneratorModal.tsx
Normal file
432
frontend/src/components/Chart/ChartGeneratorModal.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { chartApi, ChartGenerateResponse } from '../../services/chartApi';
|
||||
|
||||
interface ChartGeneratorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultText?: string;
|
||||
context?: {
|
||||
title?: string;
|
||||
section?: any;
|
||||
outline?: any;
|
||||
research?: any;
|
||||
sectionId?: string;
|
||||
};
|
||||
onChartGenerated?: (result: ChartGenerateResponse & { sectionId?: string }) => void;
|
||||
}
|
||||
|
||||
const VALID_CHART_TYPES = [
|
||||
{ value: 'bar_comparison', label: 'Bar Comparison' },
|
||||
{ value: 'bar_horizontal', label: 'Horizontal Bar' },
|
||||
{ value: 'line_trend', label: 'Line Trend' },
|
||||
{ value: 'pie', label: 'Pie Chart' },
|
||||
{ value: 'stacked_bar', label: 'Stacked Bar' },
|
||||
{ value: 'bullet_points', label: 'Bullet Points' },
|
||||
];
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
width: '100%',
|
||||
maxWidth: '680px',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '90vh',
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: '#202124',
|
||||
};
|
||||
|
||||
const ChartGeneratorModal: React.FC<ChartGeneratorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultText,
|
||||
context,
|
||||
onChartGenerated,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<'ai' | 'manual'>('ai');
|
||||
const [textInput, setTextInput] = useState(defaultText || '');
|
||||
const [chartType, setChartType] = useState('bar_comparison');
|
||||
const [title, setTitle] = useState(context?.title || '');
|
||||
const [chartDataJson, setChartDataJson] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ChartGenerateResponse | null>(null);
|
||||
const [resolvedPreviewUrl, setResolvedPreviewUrl] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (previewResult?.preview_url) {
|
||||
chartApi.getPreviewUrl(previewResult.preview_url).then(setResolvedPreviewUrl);
|
||||
} else {
|
||||
setResolvedPreviewUrl('');
|
||||
}
|
||||
}, [previewResult]);
|
||||
|
||||
const sectionTitle = context?.section?.heading || context?.title || 'Generate Chart';
|
||||
|
||||
const handleAiGenerate = useCallback(async () => {
|
||||
if (!textInput.trim()) return;
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setPreviewResult(null);
|
||||
try {
|
||||
const sectionHeading = context?.section?.heading || context?.title || '';
|
||||
const sectionKeyPoints = context?.section?.key_points || undefined;
|
||||
const result = await chartApi.generateChartFromText(textInput, title, sectionHeading, sectionKeyPoints);
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
console.warn('[ChartGenerator] Warnings:', result.warnings);
|
||||
}
|
||||
if (result.preview_url) {
|
||||
setPreviewResult(result);
|
||||
} else {
|
||||
setError('Chart generation returned empty result. Try different text.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || 'Failed to generate chart');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [textInput, title, context]);
|
||||
|
||||
const handleManualGenerate = useCallback(async () => {
|
||||
if (!chartDataJson.trim()) {
|
||||
setError('Please provide chart data JSON');
|
||||
return;
|
||||
}
|
||||
let parsedData: Record<string, any>;
|
||||
try {
|
||||
parsedData = JSON.parse(chartDataJson);
|
||||
} catch {
|
||||
setError('Invalid JSON format for chart data');
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setPreviewResult(null);
|
||||
try {
|
||||
const result = await chartApi.generateChartExplicit({
|
||||
chart_data: parsedData,
|
||||
chart_type: chartType,
|
||||
title,
|
||||
});
|
||||
if (result.preview_url) {
|
||||
setPreviewResult(result);
|
||||
} else {
|
||||
setError('Chart generation returned empty result. Check chart data format.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || 'Failed to generate chart');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [chartDataJson, chartType, title]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (previewResult && onChartGenerated) {
|
||||
onChartGenerated({
|
||||
...previewResult,
|
||||
sectionId: context?.section?.id || context?.sectionId,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}, [previewResult, onChartGenerated, context, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={headerStyle}>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>{sectionTitle} — Chart</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%)',
|
||||
color: '#5f6368',
|
||||
borderRadius: 8,
|
||||
padding: '8px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 20, overflow: 'auto', flex: 1 }}>
|
||||
{/* Mode Selector */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
onClick={() => setMode('ai')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
border: `2px solid ${mode === 'ai' ? '#4f46e5' : '#e0e0e0'}`,
|
||||
borderRadius: 8,
|
||||
background: mode === 'ai' ? '#eef2ff' : '#fff',
|
||||
color: mode === 'ai' ? '#4f46e5' : '#666',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
✨ AI Generate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('manual')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
border: `2px solid ${mode === 'manual' ? '#4f46e5' : '#e0e0e0'}`,
|
||||
borderRadius: 8,
|
||||
background: mode === 'manual' ? '#eef2ff' : '#fff',
|
||||
color: mode === 'manual' ? '#4f46e5' : '#666',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
📊 Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
||||
Chart Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Optional chart title..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'ai' ? (
|
||||
/* AI Mode */
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
||||
Text to Visualize
|
||||
</label>
|
||||
<textarea
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
placeholder="Paste or type text containing data, statistics, or key points. The AI will determine the best chart type and extract the data automatically."
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiGenerate}
|
||||
disabled={isGenerating || !textInput.trim()}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: '10px 24px',
|
||||
background: isGenerating || !textInput.trim() ? '#ccc' : '#4f46e5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : '🪄 Generate Chart from Text'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Manual Mode */
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
||||
Chart Type
|
||||
</label>
|
||||
<select
|
||||
value={chartType}
|
||||
onChange={(e) => setChartType(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{VALID_CHART_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
|
||||
Chart Data (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={chartDataJson}
|
||||
onChange={(e) => setChartDataJson(e.target.value)}
|
||||
placeholder={`{\n "labels": ["A", "B", "C"],\n "values": [30, 50, 20]\n}`}
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
fontSize: '13px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleManualGenerate}
|
||||
disabled={isGenerating || !chartDataJson.trim()}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: '10px 24px',
|
||||
background: isGenerating || !chartDataJson.trim() ? '#ccc' : '#4f46e5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : '📊 Generate Chart'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fca5a5',
|
||||
borderRadius: 8,
|
||||
color: '#991b1b',
|
||||
fontSize: '13px',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{previewResult?.warnings && previewResult.warnings.length > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fbbf24',
|
||||
borderRadius: 8,
|
||||
color: '#92400e',
|
||||
fontSize: '13px',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<strong>Note:</strong> {previewResult.warnings.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{previewResult && previewResult.preview_url && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 8 }}>
|
||||
Preview {previewResult.chart_type && (
|
||||
<span style={{ color: '#666', fontWeight: 400, marginLeft: 8 }}>
|
||||
({previewResult.chart_type.replace(/_/g, ' ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<img
|
||||
src={resolvedPreviewUrl}
|
||||
alt="Chart preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e0e0',
|
||||
background: '#1a1a1a',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 20px',
|
||||
background: '#16a34a',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✓ Use This Chart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewResult(null)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 8,
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartGeneratorModal;
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
Star,
|
||||
Refresh,
|
||||
Warning,
|
||||
ArrowBack,
|
||||
Add,
|
||||
InfoOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { DashboardHeaderProps } from '../shared/types';
|
||||
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
|
||||
@@ -134,7 +138,6 @@ export const AssetLibrary: React.FC = () => {
|
||||
case 'blog_writer':
|
||||
return {
|
||||
title: 'Blog Posts',
|
||||
subtitle: 'Manage and review your published blog posts.',
|
||||
};
|
||||
case 'research_tools':
|
||||
return {
|
||||
@@ -377,38 +380,66 @@ export const AssetLibrary: React.FC = () => {
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Asset Library
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography
|
||||
variant="h3"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{urlSourceModule === 'blog_writer' ? 'Blog Posts' : 'Asset Library'}
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more. Your outputs are stored permanently. Download and organize them for easy access across all your projects."
|
||||
arrow
|
||||
placement="bottom-start"
|
||||
>
|
||||
<InfoOutlined sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 20, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* Reminder Banner */}
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<Warning />}
|
||||
sx={{
|
||||
background: 'rgba(245,158,11,0.1)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
Your outputs are stored permanently. Download and organize them for easy access across all your projects.
|
||||
</Typography>
|
||||
</Alert>
|
||||
{/* Context-aware navigation for blog_writer source */}
|
||||
{urlSourceModule === 'blog_writer' && (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/blog-writer')}
|
||||
sx={{
|
||||
color: '#c7d2fe',
|
||||
borderColor: 'rgba(99,102,241,0.4)',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(99,102,241,0.8)',
|
||||
background: 'rgba(99,102,241,0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Back to Blog Writer
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/blog-writer?new=true')}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
New Blog
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
|
||||
476
frontend/src/components/Link/LinkSearchModal.tsx
Normal file
476
frontend/src/components/Link/LinkSearchModal.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { linkApi, LinkSearchResult } from '../../services/linkApi';
|
||||
|
||||
interface LinkSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sectionHeading?: string;
|
||||
sectionText?: string;
|
||||
selectedText?: string;
|
||||
context?: {
|
||||
title?: string;
|
||||
section?: any;
|
||||
outline?: any;
|
||||
research?: any;
|
||||
sectionId?: string;
|
||||
};
|
||||
onRewordAccept?: (rewordedText: string, sectionId?: string) => void;
|
||||
}
|
||||
|
||||
const SEO_TIPS = {
|
||||
internal: {
|
||||
title: 'Internal Links',
|
||||
icon: '🏠',
|
||||
color: '#10b981',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
description: 'Link to other pages on your own website. This helps search engines understand your site structure and distributes page authority (link equity) across your pages.',
|
||||
benefits: [
|
||||
'Distributes page authority across your site',
|
||||
'Helps search engines discover and index your pages',
|
||||
'Reduces bounce rate by guiding readers to related content',
|
||||
'Builds topical clusters that boost keyword rankings',
|
||||
],
|
||||
bestPractice: 'Use descriptive anchor text that includes relevant keywords. Aim for 2-4 internal links per 1,000 words.',
|
||||
},
|
||||
external: {
|
||||
title: 'External Links',
|
||||
icon: '🌐',
|
||||
color: '#6366f1',
|
||||
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
|
||||
description: 'Link to authoritative external sources. Search engines use outbound links as a trust signal — citing credible sources improves your content\'s E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness).',
|
||||
benefits: [
|
||||
'Signals topical authority to search engines',
|
||||
'Improves E-E-A-T (Experience, Expertise, Authoritativeness, Trust)',
|
||||
'Builds relationships with other content creators',
|
||||
'Provides readers with deeper, verified information',
|
||||
],
|
||||
bestPractice: 'Link to high-DA (Domain Authority) sources like research papers, official docs, and industry leaders. Use 1-2 external links per section.',
|
||||
},
|
||||
};
|
||||
|
||||
const LinkSearchModal: React.FC<LinkSearchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
sectionHeading,
|
||||
sectionText,
|
||||
selectedText,
|
||||
context,
|
||||
onRewordAccept,
|
||||
}) => {
|
||||
const [linkType, setLinkType] = useState<'internal' | 'external'>('external');
|
||||
const [siteUrl, setSiteUrl] = useState(() => localStorage.getItem('linkSearch_siteUrl') || '');
|
||||
const [searchQuery, setSearchQuery] = useState(sectionHeading || '');
|
||||
const [results, setResults] = useState<LinkSearchResult[]>([]);
|
||||
const [selectedLinks, setSelectedLinks] = useState<Set<number>>(new Set());
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isRewording, setIsRewording] = useState(false);
|
||||
const [rewordedText, setRewordedText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showContext, setShowContext] = useState(false);
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
|
||||
const tipStyle = SEO_TIPS[linkType];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setResults([]);
|
||||
setSelectedLinks(new Set());
|
||||
setWarnings([]);
|
||||
setRewordedText('');
|
||||
setError(null);
|
||||
setShowContext(false);
|
||||
setShowTips(false);
|
||||
const sec = context?.section;
|
||||
const heading = sectionHeading || sec?.heading || '';
|
||||
const keyPoints = sec?.key_points?.join(' ') || '';
|
||||
setSearchQuery(keyPoints ? `${heading} ${keyPoints}`.trim() : heading);
|
||||
setSiteUrl(localStorage.getItem('linkSearch_siteUrl') || '');
|
||||
}
|
||||
}, [isOpen, sectionHeading, context, selectedText]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
if (linkType === 'internal' && !siteUrl.trim()) {
|
||||
setError('Please enter your website URL for internal link search.');
|
||||
return;
|
||||
}
|
||||
if (siteUrl.trim()) {
|
||||
localStorage.setItem('linkSearch_siteUrl', siteUrl.trim());
|
||||
}
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
setWarnings([]);
|
||||
setResults([]);
|
||||
setSelectedLinks(new Set());
|
||||
setRewordedText('');
|
||||
try {
|
||||
const response = await linkApi.searchLinks({
|
||||
query: searchQuery,
|
||||
link_type: linkType,
|
||||
site_url: linkType === 'internal' ? siteUrl.trim() : siteUrl.trim() || undefined,
|
||||
num_results: 8,
|
||||
});
|
||||
setResults(response.results || []);
|
||||
setWarnings(response.warnings || []);
|
||||
if ((response.results || []).length === 0) {
|
||||
setError(linkType === 'internal'
|
||||
? 'No internal links found. Make sure your site URL is correct and publicly accessible.'
|
||||
: 'No external links found for this query. Try a different search term.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || 'Search failed');
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchQuery, linkType, siteUrl]);
|
||||
|
||||
const toggleLink = useCallback((index: number) => {
|
||||
setSelectedLinks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index); else next.add(index);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleReword = useCallback(async () => {
|
||||
if (selectedLinks.size === 0 || !sectionText) return;
|
||||
setIsRewording(true);
|
||||
setError(null);
|
||||
setRewordedText('');
|
||||
try {
|
||||
const linksToInclude = Array.from(selectedLinks).map(i => ({
|
||||
url: results[i].url,
|
||||
title: results[i].title,
|
||||
}));
|
||||
const response = await linkApi.rewordWithLinks({
|
||||
section_text: sectionText,
|
||||
selected_text: selectedText || undefined,
|
||||
section_heading: sectionHeading || undefined,
|
||||
links: linksToInclude,
|
||||
});
|
||||
setRewordedText(response.reworded_text);
|
||||
setWarnings(prev => [...prev, ...response.warnings]);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err?.message || 'Reword failed');
|
||||
} finally {
|
||||
setIsRewording(false);
|
||||
}
|
||||
}, [selectedLinks, results, sectionText, selectedText, sectionHeading]);
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
if (rewordedText && onRewordAccept) {
|
||||
onRewordAccept(rewordedText, context?.sectionId);
|
||||
}
|
||||
onClose();
|
||||
}, [rewordedText, onRewordAccept, context, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const contextSummary = [
|
||||
sectionHeading ? `Heading: "${sectionHeading}"` : null,
|
||||
selectedText ? `Selected text: "${selectedText.substring(0, 80)}${selectedText.length > 80 ? '...' : ''}"` : null,
|
||||
sectionText ? `Section text: ${sectionText.length} chars` : null,
|
||||
`Search query: "${searchQuery}"`,
|
||||
`Link type: ${linkType}`,
|
||||
siteUrl ? `Site URL: ${siteUrl}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.55)', zIndex: 2000, display: 'flex', justifyContent: 'center', alignItems: 'center' }} onClick={onClose}>
|
||||
<div style={{ background: '#fff', width: '100%', maxWidth: '780px', borderRadius: 16, overflow: 'hidden', display: 'flex', flexDirection: 'column', maxHeight: '90vh', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header with gradient */}
|
||||
<div style={{ background: tipStyle.gradient, padding: '20px 24px', color: 'white', position: 'relative' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'white', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: '24px' }}>{tipStyle.icon}</span>
|
||||
{sectionHeading || 'Find Links'}
|
||||
</h3>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '13px', color: 'rgba(255,255,255,0.85)', lineHeight: 1.4 }}>
|
||||
{tipStyle.description}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowTips(!showTips)}
|
||||
style={{ background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 6, padding: '6px 12px', color: 'white', fontSize: '12px', cursor: 'pointer', fontWeight: 500 }}
|
||||
title="SEO tips and best practices"
|
||||
>
|
||||
💡 SEO Tips
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 6, padding: '6px 12px', color: 'white', fontSize: '12px', cursor: 'pointer', fontWeight: 500 }}
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO tips expandable */}
|
||||
{showTips && (
|
||||
<div style={{ marginTop: 12, padding: '12px 16px', background: 'rgba(255,255,255,0.15)', borderRadius: 8, backdropFilter: 'blur(4px)' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'white', marginBottom: 6 }}>
|
||||
Why {linkType === 'internal' ? 'Internal' : 'External'} Links Matter for SEO:
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, color: 'rgba(255,255,255,0.9)', fontSize: '12px', lineHeight: 1.6 }}>
|
||||
{tipStyle.benefits.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontSize: '12px', color: 'rgba(255,255,255,0.8)', fontStyle: 'italic', background: 'rgba(0,0,0,0.15)', padding: '8px 12px', borderRadius: 6 }}>
|
||||
💡 Best practice: {tipStyle.bestPractice}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 20, overflow: 'auto', flex: 1 }}>
|
||||
|
||||
{/* Link Type Selector */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 16, borderRadius: 10, overflow: 'hidden', border: '1px solid #e5e7eb' }}>
|
||||
<button
|
||||
onClick={() => { setLinkType('external'); setResults([]); setRewordedText(''); setSelectedLinks(new Set()); setError(null); }}
|
||||
style={{
|
||||
flex: 1, padding: '12px 16px', border: 'none',
|
||||
background: linkType === 'external' ? 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)' : '#fafafa',
|
||||
color: linkType === 'external' ? 'white' : '#666',
|
||||
fontWeight: 600, fontSize: '14px', cursor: 'pointer', transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
🌐 External Links
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLinkType('internal'); setResults([]); setRewordedText(''); setSelectedLinks(new Set()); setError(null); }}
|
||||
style={{
|
||||
flex: 1, padding: '12px 16px', border: 'none',
|
||||
background: linkType === 'internal' ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#fafafa',
|
||||
color: linkType === 'internal' ? 'white' : '#666',
|
||||
fontWeight: 600, fontSize: '14px', cursor: 'pointer', transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
🏠 Internal Links
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Site URL */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '13px', fontWeight: 600, color: '#374151', marginBottom: 4 }}>
|
||||
{linkType === 'internal' ? '🔗 Your Website URL (required)' : '🔗 Your Website URL (optional — excludes your site from results)'}
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontWeight: 400 }}>(saved for next time)</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={siteUrl}
|
||||
onChange={e => {
|
||||
setSiteUrl(e.target.value);
|
||||
if (e.target.value.trim()) localStorage.setItem('linkSearch_siteUrl', e.target.value.trim());
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: '14px', boxSizing: 'border-box', outline: 'none', transition: 'border-color 0.2s' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = '#6366f1'; }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = '#d1d5db'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Query */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#374151', marginBottom: 4 }}>
|
||||
🔍 Search Query
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Topic or section heading..."
|
||||
style={{ flex: 1, padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: '14px', outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
background: isSearching || !searchQuery.trim() ? '#d1d5db' : tipStyle.gradient,
|
||||
color: 'white', border: 'none', borderRadius: 8, fontSize: '14px', fontWeight: 600,
|
||||
cursor: isSearching ? 'not-allowed' : 'pointer',
|
||||
boxShadow: isSearching || !searchQuery.trim() ? 'none' : '0 2px 8px rgba(99,102,241,0.3)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{isSearching ? '⏳ Searching...' : '🔍 Search'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context toggle */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setShowContext(!showContext)}
|
||||
style={{ background: 'none', border: '1px solid #e5e7eb', borderRadius: 6, padding: '4px 10px', fontSize: '11px', color: '#6b7280', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
📋 {showContext ? 'Hide' : 'Show'} what we're sending to AI
|
||||
</button>
|
||||
{showContext && (
|
||||
<div style={{ marginTop: 6, padding: '10px 12px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: '11px', color: '#6b7280', lineHeight: 1.6, whiteSpace: 'pre-wrap', maxHeight: 120, overflowY: 'auto' }}>
|
||||
{contextSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div style={{ padding: '10px 14px', background: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 8, color: '#92400e', fontSize: '13px', marginBottom: 12 }}>
|
||||
<strong>⚠️ Note:</strong> {warnings.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ padding: '12px 16px', background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 8, color: '#991b1b', fontSize: '13px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: '16px' }}>❌</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{results.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: '#374151' }}>
|
||||
Found {results.length} link{results.length !== 1 ? 's' : ''} — select to include:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedLinks.size === results.length) setSelectedLinks(new Set());
|
||||
else setSelectedLinks(new Set(results.map((_, i) => i)));
|
||||
}}
|
||||
style={{ fontSize: '11px', color: '#6366f1', background: 'none', border: '1px solid #e0e7ff', borderRadius: 4, cursor: 'pointer', padding: '3px 8px', fontWeight: 500 }}
|
||||
>
|
||||
{selectedLinks.size === results.length ? '✓ Deselect All' : `✓ Select All (${results.length})`}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: '240px', overflowY: 'auto' }}>
|
||||
{results.map((result, index) => (
|
||||
<label
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 12px',
|
||||
background: selectedLinks.has(index) ? 'linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%)' : '#fafafa',
|
||||
border: `1px solid ${selectedLinks.has(index) ? '#6366f1' : '#e5e7eb'}`,
|
||||
borderRadius: 8, cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLinks.has(index)}
|
||||
onChange={() => toggleLink(index)}
|
||||
style={{ marginTop: 4, accentColor: '#6366f1' }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500, color: '#1f2937', marginBottom: 2 }}>
|
||||
{result.title || 'Untitled'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6366f1', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<a href={result.url} target="_blank" rel="noopener noreferrer" style={{ color: '#6366f1', textDecoration: 'none' }} onClick={e => e.stopPropagation()}>
|
||||
{result.url} ↗
|
||||
</a>
|
||||
</div>
|
||||
{result.text && (
|
||||
<div style={{ fontSize: '12px', color: '#6b7280', lineHeight: 1.4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{result.text.substring(0, 200)}{result.text.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.score > 0 && (
|
||||
<span style={{ fontSize: '10px', color: '#9ca3af', whiteSpace: 'nowrap', background: '#f3f4f6', padding: '2px 6px', borderRadius: 4 }}>
|
||||
relevance {result.score.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reword Section */}
|
||||
{selectedLinks.size > 0 && !rewordedText && (
|
||||
sectionText ? (
|
||||
<button
|
||||
onClick={handleReword}
|
||||
disabled={isRewording}
|
||||
style={{
|
||||
width: '100%', padding: '14px 24px',
|
||||
background: isRewording ? '#d1d5db' : 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white', border: 'none', borderRadius: 10, fontSize: '15px', fontWeight: 600,
|
||||
cursor: isRewording ? 'not-allowed' : 'pointer', marginBottom: 12,
|
||||
boxShadow: isRewording ? 'none' : '0 4px 12px rgba(16,185,129,0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{isRewording ? '⏳ Rewording with AI...' : `✨ Reword with ${selectedLinks.size} Link${selectedLinks.size !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ padding: '14px 16px', background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', border: '1px solid #93c5fd', borderRadius: 10, color: '#1e40af', fontSize: '13px', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
<strong>💡 Tip:</strong> Select links above and copy their URLs to insert manually. The "Reword with Links" feature requires section text context, which isn't available here — but works when you select text in the editor.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Reworded Result */}
|
||||
{rewordedText && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937' }}>
|
||||
✨ Reworded Text
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||
{selectedLinks.size} link{selectedLinks.size !== 1 ? 's' : ''} incorporated
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||
border: '1px solid #bbf7d0',
|
||||
borderRadius: 10, fontSize: '14px', lineHeight: 1.7, color: '#1f2937',
|
||||
maxHeight: '220px', overflowY: 'auto', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{rewordedText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
style={{
|
||||
flex: 1, padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', color: 'white',
|
||||
border: 'none', borderRadius: 10, fontSize: '14px', fontWeight: 600, cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(16,185,129,0.3)',
|
||||
}}
|
||||
>
|
||||
✅ Use This Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRewordedText(''); }}
|
||||
style={{
|
||||
padding: '12px 20px', background: '#f9fafb', color: '#6b7280',
|
||||
border: '1px solid #e5e7eb', borderRadius: 10, fontSize: '14px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
🔄 Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkSearchModal;
|
||||
@@ -42,9 +42,6 @@ export const usePlatformConnections = () => {
|
||||
});
|
||||
setToastMessage('Wix account connected successfully!');
|
||||
setShowToast(true);
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
}
|
||||
}, [setConnectedPlatforms, setToastMessage]);
|
||||
|
||||
@@ -79,7 +76,13 @@ export const usePlatformConnections = () => {
|
||||
// 2) Key by state so callback can look up by state value
|
||||
try { sessionStorage.setItem(`wix_oauth_data_${oauthData.state}`, JSON.stringify(oauthData)); } catch {}
|
||||
// 3) window.name persists across top-level redirects even when origin changes
|
||||
try { (window as any).name = `WIX_OAUTH::${btoa(JSON.stringify(oauthData))}`; } catch {}
|
||||
try {
|
||||
const redirectTo = sessionStorage.getItem('wix_oauth_redirect') || window.location.href;
|
||||
console.log('[handleWixConnect] Storing redirect_to in window.name:', redirectTo);
|
||||
(window as any).name = `WIX_OAUTH::${btoa(JSON.stringify({ ...oauthData, redirect_to: redirectTo }))}`;
|
||||
} catch (e) {
|
||||
console.error('[handleWixConnect] Failed to set window.name:', e);
|
||||
}
|
||||
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
ALwrity Podcast Maker
|
||||
Podcast Creator
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
|
||||
<AudioIcon color="primary" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Podcast Maker"
|
||||
primary="Podcast Creator"
|
||||
secondary="AI-powered research, scriptwriting, and voice narration"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
const FALLBACK_ORIGIN = 'http://localhost:3000';
|
||||
|
||||
const WixCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -15,7 +17,7 @@ const WixCallbackPage: React.FC = () => {
|
||||
setError(`${error}: ${errorDescription || ''}`);
|
||||
return;
|
||||
}
|
||||
// Recover oauthData via multiple fallbacks
|
||||
|
||||
let oauthData: any | null = null;
|
||||
const saved = sessionStorage.getItem('wix_oauth_data') || localStorage.getItem('wix_oauth_data');
|
||||
if (saved) {
|
||||
@@ -28,102 +30,85 @@ const WixCallbackPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
if (!oauthData && typeof window.name === 'string' && window.name.startsWith('WIX_OAUTH::')) {
|
||||
try { oauthData = JSON.parse(atob(window.name.replace('WIX_OAUTH::',''))); } catch {}
|
||||
try { oauthData = JSON.parse(atob(window.name.replace('WIX_OAUTH::', ''))); } catch {}
|
||||
}
|
||||
if (!oauthData) {
|
||||
setError('Missing OAuth state. Please start the connection again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for tokens via backend to ensure persistence and get site info
|
||||
let accessToken: string | null = null;
|
||||
let siteInfo: any = null;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/auth/callback', {
|
||||
code,
|
||||
state
|
||||
});
|
||||
|
||||
const response = await apiClient.post('/api/wix/auth/callback', { code, state });
|
||||
if (response.data.success) {
|
||||
const { tokens, site_info, permissions } = response.data;
|
||||
|
||||
// Store tokens and site info
|
||||
try {
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
|
||||
if (site_info) {
|
||||
sessionStorage.setItem('wix_site_info', JSON.stringify(site_info));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Mark frontend session as connected
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
|
||||
// Cleanup saved oauth data
|
||||
sessionStorage.removeItem('wix_oauth_data');
|
||||
sessionStorage.removeItem(`wix_oauth_data_${state}`);
|
||||
localStorage.removeItem('wix_oauth_data');
|
||||
try { (window as any).name = ''; } catch {}
|
||||
|
||||
// Notify opener (if opened as popup) and close
|
||||
try {
|
||||
const payload = {
|
||||
type: 'WIX_OAUTH_SUCCESS',
|
||||
success: true,
|
||||
tokens,
|
||||
site_info
|
||||
} as any;
|
||||
(window.opener || window.parent)?.postMessage(payload, '*');
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback redirect for same-tab flow
|
||||
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
|
||||
if (redirectUrl) {
|
||||
try {
|
||||
const urlObj = new URL(redirectUrl);
|
||||
const currentOrigin = window.location.origin;
|
||||
if (urlObj.origin !== currentOrigin) {
|
||||
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
sessionStorage.removeItem('wix_oauth_redirect');
|
||||
window.location.replace(redirectUrl);
|
||||
} else {
|
||||
// Default redirect
|
||||
const referrer = document.referrer;
|
||||
const isFromBlogWriter = referrer.includes('/blog-writer') ||
|
||||
window.location.search.includes('from=blog-writer');
|
||||
|
||||
if (isFromBlogWriter) {
|
||||
window.location.replace('/blog-writer#publish');
|
||||
} else {
|
||||
window.location.replace('/onboarding?step=5&wix_connected=true');
|
||||
}
|
||||
}
|
||||
const { tokens, site_info } = response.data;
|
||||
accessToken = tokens?.access_token || tokens?.accessToken?.value || null;
|
||||
siteInfo = site_info || null;
|
||||
} else {
|
||||
throw new Error(response.data.message || 'Connection failed');
|
||||
}
|
||||
} catch (backendError: any) {
|
||||
console.error('Backend exchange failed, falling back to client-side:', backendError);
|
||||
// Fallback to client-side exchange if backend fails
|
||||
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
|
||||
wixClient.auth.setTokens(tokens);
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
|
||||
// ... rest of the cleanup and redirect logic ...
|
||||
sessionStorage.removeItem('wix_oauth_data');
|
||||
// (Simplified fallback for brevity, assuming backend usually works)
|
||||
try {
|
||||
const payload = { type: 'WIX_OAUTH_SUCCESS', success: true, tokens } as any;
|
||||
(window.opener || window.parent)?.postMessage(payload, '*');
|
||||
if (window.opener) { window.close(); return; }
|
||||
} catch {}
|
||||
window.location.replace('/onboarding?step=5&wix_connected=true');
|
||||
accessToken = (tokens as any)?.accessToken?.value || (tokens as any)?.access_token || null;
|
||||
}
|
||||
|
||||
// Store in current origin's storage (may be ngrok — not accessible from localhost,
|
||||
// but useful if the callback runs on the same origin as the app)
|
||||
try {
|
||||
if (accessToken) localStorage.setItem('wix_access_token', accessToken);
|
||||
} catch {}
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
|
||||
// Cleanup oauth data
|
||||
sessionStorage.removeItem('wix_oauth_data');
|
||||
if (state) sessionStorage.removeItem(`wix_oauth_data_${state}`);
|
||||
localStorage.removeItem('wix_oauth_data');
|
||||
|
||||
// CRITICAL: Put access_token + site_info into window.name so it survives
|
||||
// the cross-origin redirect (ngrok → localhost). window.name persists
|
||||
// across same-tab navigations even when the origin changes.
|
||||
try {
|
||||
const payload = { access_token: accessToken, site_info: siteInfo };
|
||||
(window as any).name = `WIX_RESULT::${btoa(JSON.stringify(payload))}`;
|
||||
} catch {}
|
||||
|
||||
// Notify opener if popup
|
||||
try {
|
||||
const targetOrigin = window.location.ancestorOrigins?.[0] || '*';
|
||||
(window.opener || window.parent)?.postMessage(
|
||||
{ type: 'WIX_OAUTH_SUCCESS', success: true, access_token: accessToken, site_info: siteInfo },
|
||||
targetOrigin
|
||||
);
|
||||
if (window.opener) { window.close(); return; }
|
||||
} catch {}
|
||||
|
||||
localStorage.setItem('blogwriter_current_phase', 'publish');
|
||||
localStorage.setItem('blogwriter_user_selected_phase', 'true');
|
||||
|
||||
// Build redirect URL. oauthData.redirect_to was set by WixConnectModal
|
||||
// to the user's actual origin (e.g. http://localhost:3000/blog-writer#publish).
|
||||
// sessionStorage is per-origin so wix_oauth_redirect may be null on ngrok.
|
||||
let redirectUrl = oauthData?.redirect_to || sessionStorage.getItem('wix_oauth_redirect');
|
||||
if (redirectUrl) {
|
||||
sessionStorage.removeItem('wix_oauth_redirect');
|
||||
try {
|
||||
const urlObj = new URL(redirectUrl);
|
||||
urlObj.searchParams.set('wix_connected', 'true');
|
||||
redirectUrl = urlObj.toString();
|
||||
} catch {
|
||||
redirectUrl = `${redirectUrl}?wix_connected=true`;
|
||||
}
|
||||
} else {
|
||||
// Fallback: construct localhost URL
|
||||
redirectUrl = `${FALLBACK_ORIGIN}/blog-writer?wix_connected=true#publish`;
|
||||
}
|
||||
|
||||
window.location.replace(redirectUrl);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
try {
|
||||
@@ -150,5 +135,3 @@ const WixCallbackPage: React.FC = () => {
|
||||
};
|
||||
|
||||
export default WixCallbackPage;
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const endpointToTool = (endpoint: string): string => {
|
||||
return 'Story Writer';
|
||||
}
|
||||
if (endpointLower.includes('podcast') || endpointLower.includes('podcast-maker')) {
|
||||
return 'Podcast Maker';
|
||||
return 'Podcast Creator';
|
||||
}
|
||||
if (endpointLower.includes('image') || endpointLower.includes('image-studio')) {
|
||||
return 'Image Studio';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider } from '@mui/material';
|
||||
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider, IconButton, CircularProgress } from '@mui/material';
|
||||
import { useUser, useClerk } from '@clerk/clerk-react';
|
||||
import { useSubscription } from '../../contexts/SubscriptionContext';
|
||||
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
logBackendCooldownSkipOnce,
|
||||
} from '../../api/client';
|
||||
import { saveNavigationState } from '../../utils/navigationState';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface UserBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
@@ -19,9 +20,10 @@ interface UserBadgeProps {
|
||||
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { user, isSignedIn } = useUser();
|
||||
const { signOut } = useClerk();
|
||||
const { subscription } = useSubscription();
|
||||
const { subscription, refreshSubscription } = useSubscription();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
@@ -80,7 +82,8 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
|
||||
// Get plan display info
|
||||
const getPlanColor = () => {
|
||||
switch (subscription?.plan) {
|
||||
const plan = subscription?.plan?.toLowerCase() || 'free';
|
||||
switch (plan) {
|
||||
case 'free': return '#4caf50';
|
||||
case 'basic': return '#2196f3';
|
||||
case 'pro': return '#9c27b0';
|
||||
@@ -90,13 +93,29 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
};
|
||||
|
||||
const getPlanLabel = () => {
|
||||
if (!subscription?.active) return 'No Plan';
|
||||
if (!subscription?.plan) return 'Free';
|
||||
const plan = subscription.plan.toLowerCase();
|
||||
if (plan === 'free') return 'Free';
|
||||
if (plan === 'basic') return 'Basic';
|
||||
if (plan === 'pro') return 'Pro';
|
||||
if (plan === 'enterprise') return 'Enterprise';
|
||||
return subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
};
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
|
||||
const handleRefreshPlan = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshSubscription();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh subscription:', err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
@@ -121,7 +140,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'} - System: ${systemStatus.toUpperCase()}`}>
|
||||
<Tooltip title="User Navigation Menu">
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
@@ -195,22 +214,37 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
</Box>
|
||||
|
||||
{/* Subscription Info */}
|
||||
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb' }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Current Plan
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getPlanLabel()}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${getPlanColor()}15`,
|
||||
border: `1.5px solid ${getPlanColor()}40`,
|
||||
color: getPlanColor(),
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
height: 26,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Current Plan
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getPlanLabel()}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: `${getPlanColor()}15`,
|
||||
border: `1.5px solid ${getPlanColor()}40`,
|
||||
color: getPlanColor(),
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
height: 26,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title="Refresh subscription status">
|
||||
<IconButton
|
||||
onClick={handleRefreshPlan}
|
||||
size="small"
|
||||
disabled={isRefreshing}
|
||||
sx={{
|
||||
color: '#6b7280',
|
||||
'&:hover': { bgcolor: '#e5e7eb' },
|
||||
}}
|
||||
>
|
||||
{isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mx: 2 }} />
|
||||
@@ -258,6 +292,9 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
<MenuItem onClick={() => { handleClose(); saveNavigationState(window.location.pathname); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
Manage Subscription
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); window.location.href = '/billing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
|
||||
View Costing Details
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>
|
||||
Sign out
|
||||
</MenuItem>
|
||||
|
||||
@@ -41,8 +41,9 @@ interface SubscriptionContextType {
|
||||
subscription: SubscriptionStatus | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
checkSubscription: () => Promise<void>;
|
||||
checkSubscription: (force?: boolean) => Promise<void>;
|
||||
refreshSubscription: () => Promise<void>;
|
||||
verifyCheckout: () => Promise<void>;
|
||||
showExpiredModal: () => void;
|
||||
hideExpiredModal: () => void;
|
||||
}
|
||||
@@ -82,12 +83,12 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
subscriptionRef.current = subscription;
|
||||
}, [subscription]);
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
const checkSubscription = useCallback(async (force = false) => {
|
||||
// Throttle subscription checks to prevent excessive API calls
|
||||
const now = Date.now();
|
||||
const THROTTLE_MS = 5000; // 5 seconds minimum between checks
|
||||
|
||||
if (now - lastCheckTime < THROTTLE_MS) {
|
||||
if (!force && now - lastCheckTime < THROTTLE_MS) {
|
||||
console.log('SubscriptionContext: Check throttled (5s)');
|
||||
return;
|
||||
}
|
||||
@@ -304,9 +305,45 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil, isUsageLimitModal]);
|
||||
|
||||
const refreshSubscription = useCallback(async () => {
|
||||
await checkSubscription();
|
||||
await checkSubscription(true); // Force bypass throttle
|
||||
}, [checkSubscription]);
|
||||
|
||||
const verifyCheckout = useCallback(async () => {
|
||||
const userId = localStorage.getItem('user_id') || 'anonymous';
|
||||
if (userId === 'anonymous') {
|
||||
console.log('[verifyCheckout] User not authenticated, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[verifyCheckout] Querying /api/subscription/verify-checkout for user:', userId);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/subscription/verify-checkout/${userId}`);
|
||||
const subscriptionData = response.data.data;
|
||||
|
||||
console.log('[verifyCheckout] Result:', {
|
||||
active: subscriptionData?.active,
|
||||
plan: subscriptionData?.plan,
|
||||
source: subscriptionData?.source
|
||||
});
|
||||
|
||||
setSubscription(subscriptionData);
|
||||
subscriptionRef.current = subscriptionData;
|
||||
|
||||
const newSignature = `${subscriptionData?.plan || ''}:${subscriptionData?.tier || ''}`;
|
||||
if (newSignature && newSignature !== planSignature) {
|
||||
console.log('[verifyCheckout] Plan change detected:', planSignature, '→', newSignature);
|
||||
setPlanSignature(newSignature);
|
||||
setGraceUntil(Date.now() + 5 * 60 * 1000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
const detail = err?.response?.data?.detail;
|
||||
console.error('[verifyCheckout] Failed:', { status, detail, message: err?.message });
|
||||
// Do NOT fall back to checkSubscription — it returns stale DB data.
|
||||
// Let the polling retry verifyCheckout on the next attempt.
|
||||
}
|
||||
}, [planSignature]);
|
||||
|
||||
const showExpiredModal = useCallback(() => {
|
||||
setIsUsageLimitModal(false);
|
||||
setShowModal(true);
|
||||
@@ -572,8 +609,64 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
window.addEventListener('subscription-updated', handleSubscriptionUpdate);
|
||||
window.addEventListener('user-authenticated', handleUserAuth);
|
||||
|
||||
// Checkout success: if URL has ?subscription=success, poll with verifyCheckout
|
||||
// until subscription becomes active (not free). Uses refs to avoid stale closures.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
|
||||
|
||||
let checkoutPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (isCheckoutSuccess) {
|
||||
console.log('[CheckoutPoll] Checkout success detected, starting verification polling');
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
checkoutPollInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
const currentSubscription = subscriptionRef.current;
|
||||
console.log(`[CheckoutPoll] Attempt ${attempts}/${maxAttempts}, current plan: ${currentSubscription?.plan || 'unknown'}`);
|
||||
|
||||
// Check if subscription is already active (not free/none)
|
||||
if (currentSubscription && currentSubscription.active && currentSubscription.plan !== 'free' && currentSubscription.plan !== 'none') {
|
||||
console.log('[CheckoutPoll] Subscription confirmed active:', currentSubscription.plan, '- stopping poll');
|
||||
clearInterval(checkoutPollInterval!);
|
||||
checkoutPollInterval = null;
|
||||
// Clean URL to remove ?subscription=success
|
||||
try {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} catch (e) {
|
||||
// Ignore URL cleanup errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
console.log('[CheckoutPoll] Polling exhausted, subscription may still be processing');
|
||||
clearInterval(checkoutPollInterval!);
|
||||
checkoutPollInterval = null;
|
||||
// Clean URL even on exhaustion
|
||||
try {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCheckout();
|
||||
} catch (err) {
|
||||
console.error('[CheckoutPoll] Verification failed:', err);
|
||||
// Don't clear interval on error - retry on next attempt
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (checkoutPollInterval) {
|
||||
clearInterval(checkoutPollInterval);
|
||||
}
|
||||
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
|
||||
window.removeEventListener('user-authenticated', handleUserAuth);
|
||||
};
|
||||
@@ -585,6 +678,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
error,
|
||||
checkSubscription,
|
||||
refreshSubscription,
|
||||
verifyCheckout,
|
||||
showExpiredModal,
|
||||
hideExpiredModal,
|
||||
};
|
||||
|
||||
@@ -93,6 +93,8 @@ export const useBlogWriterState = () => {
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const [restoreAttempted, setRestoreAttempted] = useState(false);
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
const restoreState = async () => {
|
||||
@@ -135,10 +137,31 @@ export const useBlogWriterState = () => {
|
||||
}
|
||||
|
||||
console.log('Restored outline, content, and title data from localStorage');
|
||||
} catch (error) {
|
||||
// Restore seoAnalysis and seoMetadata from localStorage
|
||||
const savedSeoAnalysis = localStorage.getItem('blog_seo_analysis');
|
||||
if (savedSeoAnalysis) {
|
||||
try { setSeoAnalysis(JSON.parse(savedSeoAnalysis)); } catch {}
|
||||
}
|
||||
const savedSeoMetadata = localStorage.getItem('blog_seo_metadata');
|
||||
if (savedSeoMetadata) {
|
||||
try { setSeoMetadata(JSON.parse(savedSeoMetadata)); } catch {}
|
||||
}
|
||||
|
||||
// Restore outlineConfirmed - if outline exists and was previously confirmed, mark as confirmed.
|
||||
// The user had to confirm outline to reach content/SEO/publish phases.
|
||||
const savedOutlineConfirmed = localStorage.getItem('blog_outline_confirmed');
|
||||
if (savedOutlineConfirmed === 'true') {
|
||||
setOutlineConfirmed(true);
|
||||
} else if (savedOutline) {
|
||||
// Backward compatibility: if outline exists but outline_confirmed wasn't saved,
|
||||
// assume it was confirmed (user wouldn't have progressed without confirming).
|
||||
setOutlineConfirmed(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring outline data:', error);
|
||||
}
|
||||
}
|
||||
setRestoreAttempted(true);
|
||||
};
|
||||
|
||||
restoreState();
|
||||
@@ -151,11 +174,42 @@ export const useBlogWriterState = () => {
|
||||
} catch {}
|
||||
}, [contentConfirmed]);
|
||||
|
||||
// Persist outlineConfirmed to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('blog_outline_confirmed', String(outlineConfirmed));
|
||||
} catch {}
|
||||
}, [outlineConfirmed]);
|
||||
|
||||
// Persist seoAnalysis to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (seoAnalysis) {
|
||||
localStorage.setItem('blog_seo_analysis', JSON.stringify(seoAnalysis));
|
||||
}
|
||||
} catch {}
|
||||
}, [seoAnalysis]);
|
||||
|
||||
// Persist seoMetadata to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (seoMetadata) {
|
||||
localStorage.setItem('blog_seo_metadata', JSON.stringify(seoMetadata));
|
||||
}
|
||||
} catch {}
|
||||
}, [seoMetadata]);
|
||||
|
||||
// Persist sections to blogWriterCache whenever they change
|
||||
useEffect(() => {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
if (outlineIds.length > 0 && Object.keys(sections).length > 0) {
|
||||
blogWriterCache.cacheContent(sections, outlineIds);
|
||||
const normalized: Record<string, string> = {};
|
||||
const values = Object.values(sections);
|
||||
outline.forEach((s, idx) => {
|
||||
const id = String(s.id);
|
||||
normalized[id] = sections[id] ?? values[idx] ?? '';
|
||||
});
|
||||
blogWriterCache.cacheContent(normalized, outlineIds);
|
||||
}
|
||||
}, [sections, outline]);
|
||||
|
||||
@@ -316,6 +370,7 @@ export const useBlogWriterState = () => {
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
restoreAttempted,
|
||||
|
||||
// Setters
|
||||
setResearch,
|
||||
|
||||
102
frontend/src/hooks/useGSCBrainstorm.ts
Normal file
102
frontend/src/hooks/useGSCBrainstorm.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
gscBrainstormAPI,
|
||||
BrainstormResult,
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
AIRecommendations,
|
||||
BrainstormSummary,
|
||||
} from '../api/gscBrainstorm';
|
||||
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
|
||||
|
||||
interface UseGSCBrainstormReturn {
|
||||
gscConnected: boolean;
|
||||
gscSites: { siteUrl: string; permissionLevel: string }[] | null;
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
isBrainstorming: boolean;
|
||||
brainstormError: string | null;
|
||||
brainstormResult: BrainstormResult | null;
|
||||
contentOpportunities: ContentOpportunity[];
|
||||
keywordGaps: KeywordGap[];
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
connectGSC: () => Promise<void>;
|
||||
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
const { getToken } = useAuth();
|
||||
const {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
checkConnection,
|
||||
connectGSC,
|
||||
} = useGSCBrainstormConnection();
|
||||
|
||||
const [isBrainstorming, setIsBrainstorming] = useState(false);
|
||||
const [brainstormError, setBrainstormError] = useState<string | null>(null);
|
||||
const [brainstormResult, setBrainstormResult] = useState<BrainstormResult | null>(null);
|
||||
|
||||
const brainstorm = useCallback(
|
||||
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
||||
setIsBrainstorming(true);
|
||||
setBrainstormError(null);
|
||||
|
||||
try {
|
||||
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await gscBrainstormAPI.brainstorm(keywords, siteUrl);
|
||||
setBrainstormResult(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to brainstorm topics. Please try again.';
|
||||
setBrainstormError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBrainstorming(false);
|
||||
}
|
||||
},
|
||||
[getToken],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setBrainstormResult(null);
|
||||
setBrainstormError(null);
|
||||
setIsBrainstorming(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
isBrainstorming,
|
||||
brainstormError,
|
||||
brainstormResult,
|
||||
contentOpportunities: brainstormResult?.content_opportunities ?? [],
|
||||
keywordGaps: brainstormResult?.keyword_gaps ?? [],
|
||||
aiRecommendations: brainstormResult?.ai_recommendations
|
||||
&& Object.keys(brainstormResult.ai_recommendations).length > 0
|
||||
? (brainstormResult.ai_recommendations as AIRecommendations)
|
||||
: null,
|
||||
summary: brainstormResult?.summary
|
||||
&& Object.keys(brainstormResult.summary).length > 0
|
||||
? (brainstormResult.summary as BrainstormSummary)
|
||||
: null,
|
||||
connectGSC,
|
||||
brainstorm,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
162
frontend/src/hooks/useGSCBrainstormConnection.ts
Normal file
162
frontend/src/hooks/useGSCBrainstormConnection.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { gscAPI, GSCSite } from '../api/gsc';
|
||||
import { cachedAnalyticsAPI } from '../api/cachedAnalytics';
|
||||
|
||||
interface UseGSCBrainstormConnectionReturn {
|
||||
gscConnected: boolean;
|
||||
gscSites: GSCSite[] | null;
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
connectGSC: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn => {
|
||||
const { getToken } = useAuth();
|
||||
const [gscConnected, setGscConnected] = useState(false);
|
||||
const [gscSites, setGscSites] = useState<GSCSite[] | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
gscAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}, [getToken]);
|
||||
|
||||
const checkConnection = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const status = await gscAPI.getStatus();
|
||||
if (status.connected) {
|
||||
setGscConnected(true);
|
||||
if (status.sites && status.sites.length) {
|
||||
setGscSites(status.sites);
|
||||
}
|
||||
setConnectError(null);
|
||||
return true;
|
||||
} else {
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkConnection();
|
||||
}, [checkConnection]);
|
||||
|
||||
const connectGSC = useCallback(async (): Promise<void> => {
|
||||
setIsConnecting(true);
|
||||
setConnectError(null);
|
||||
|
||||
try {
|
||||
try {
|
||||
await gscAPI.clearIncomplete();
|
||||
} catch (e) {
|
||||
console.log('Clear incomplete failed:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await gscAPI.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Disconnect failed:', e);
|
||||
}
|
||||
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
|
||||
const { auth_url } = await gscAPI.getAuthUrl();
|
||||
|
||||
const popup = window.open(
|
||||
auth_url,
|
||||
'gsc-auth',
|
||||
'width=600,height=700,scrollbars=yes,resizable=yes',
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
setConnectError('Popup blocked. Please allow popups for this site.');
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let messageHandled = false;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (messageHandled) return;
|
||||
if (!event?.data || typeof event.data !== 'object') return;
|
||||
const { type } = event.data as { type?: string };
|
||||
|
||||
if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') {
|
||||
messageHandled = true;
|
||||
try { popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
|
||||
if (type === 'GSC_AUTH_SUCCESS') {
|
||||
checkConnection().then(() => {
|
||||
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
setConnectError('Google Search Console connection was cancelled or failed.');
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (!messageHandled) {
|
||||
try { if (!popup.closed) popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
checkConnection().then(() => resolve());
|
||||
}
|
||||
}, 3 * 60 * 1000);
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
try {
|
||||
if (popup.closed) {
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (!messageHandled) {
|
||||
checkConnection().then(() => resolve());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('GSC OAuth error:', error);
|
||||
setConnectError(
|
||||
error instanceof Error ? error.message : 'Failed to connect Google Search Console.',
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [checkConnection]);
|
||||
|
||||
return {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
checkConnection,
|
||||
connectGSC,
|
||||
};
|
||||
};
|
||||
@@ -11,13 +11,70 @@ export const useMarkdownProcessor = (
|
||||
}, [outline, sections]);
|
||||
|
||||
const convertMarkdownToHTML = useCallback((md: string) => {
|
||||
return md
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '<br/><br/>');
|
||||
if (!md) return '';
|
||||
|
||||
let html = md;
|
||||
|
||||
// Headings (must be first, before other replacements)
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold and Italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url) - handle both http and data:image URLs
|
||||
html = html.replace(/\[(.+?)\]\((.+?)\)/g, (match, text, url) => {
|
||||
const safeUrl = url.replace(/"/g, '"');
|
||||
if (url.startsWith('data:image') || url.startsWith('http')) {
|
||||
return `<img src="${safeUrl}" alt="${text}" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />`;
|
||||
}
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" style="color:#4f46e5;text-decoration:underline;">${text}</a>`;
|
||||
});
|
||||
|
||||
// Images  - explicit image syntax
|
||||
html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left:4px solid #e5e7eb;margin:1rem 0;padding:0.5rem 1rem;background:#f9fafb;color:#6b7280;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:monospace;font-size:0.9em;color:#dc2626;">$1</code>');
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^-{3,}$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:1.5rem 0;" />');
|
||||
|
||||
// Unordered lists (- item or * item)
|
||||
html = html.replace(/^[-*] (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
|
||||
// Wrap consecutive <li> tags in <ul>
|
||||
html = html.replace(/(<li style="margin-bottom:0.5rem;">.+<\/li>\n?)+/g, (match) => {
|
||||
return `<ul style="padding-left:1.5rem;margin:1rem 0;list-style-type:disc;">${match}</ul>`;
|
||||
});
|
||||
|
||||
// Ordered lists (1. item, 2. item, etc.)
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
|
||||
// Wrap consecutive <li> tags in <ol> (simplified - assumes ordered lists come after unordered processing)
|
||||
|
||||
// Paragraphs (double newlines)
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = `<p>${html}</p>`;
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p><\/p>/g, '');
|
||||
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
|
||||
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<ul>)/g, '$1');
|
||||
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<ol>)/g, '$1');
|
||||
html = html.replace(/(<\/ol>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<blockquote>)/g, '$1');
|
||||
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<hr)/g, '$1');
|
||||
html = html.replace(/(<img[^>]*\/>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<img)/g, '$1');
|
||||
|
||||
return html;
|
||||
}, []);
|
||||
|
||||
const getTotalWords = useCallback(() => {
|
||||
|
||||
@@ -24,23 +24,24 @@ export const usePhaseNavigation = (
|
||||
// Initialize from localStorage if available
|
||||
// If no research exists, default to empty string to show landing page
|
||||
// Only default to 'research' if research already exists (resuming a session)
|
||||
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
|
||||
|
||||
const getInitialPhase = (): string => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('blogwriter_current_phase');
|
||||
if (stored) {
|
||||
// If stored phase is 'research' but no research exists, show landing page instead
|
||||
if (stored === 'research' && !research) {
|
||||
return ''; // Return empty to show landing page
|
||||
return '';
|
||||
}
|
||||
// For other phases, use stored value (user might be in middle of outline/content/seo/publish)
|
||||
// Even if research doesn't exist, allow other phases to be restored (edge case)
|
||||
return stored;
|
||||
}
|
||||
const hashPhase = window.location.hash.replace('#', '');
|
||||
if (hashPhase && VALID_PHASES.includes(hashPhase)) {
|
||||
return hashPhase;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Default to empty string to show landing page when no research exists
|
||||
// Will be set to 'research' when user clicks "Start Research"
|
||||
return research ? 'research' : '';
|
||||
};
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function usePolling<T = any>(
|
||||
onError?.(status.error || 'Task failed');
|
||||
|
||||
// Check if this is a subscription error and trigger modal
|
||||
if (status.error_status === 429 || status.error_status === 402) {
|
||||
if (status.error_status === 429 || status.error_status === 402 || status.error_status === 403) {
|
||||
console.log('usePolling: Detected subscription error in task status', {
|
||||
error_status: status.error_status,
|
||||
error_data: status.error_data,
|
||||
@@ -186,7 +186,7 @@ export function usePolling<T = any>(
|
||||
// Check if this is an axios error with subscription limit status
|
||||
// This is a fallback in case the interceptor doesn't catch it
|
||||
const axiosError = err as any;
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402 || axiosError?.response?.status === 403) {
|
||||
// Trigger subscription error handler (modal will show)
|
||||
// Note: The interceptor may have already called this, but we call it again to be safe
|
||||
const handled = await triggerSubscriptionError(axiosError);
|
||||
|
||||
@@ -140,7 +140,10 @@ export const useTextToSpeech = (): UseTextToSpeechReturn => {
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('Speech synthesis error:', event.error);
|
||||
// Ignore 'interrupted' errors (happens when stopping speech or switching sections)
|
||||
if (event.error !== 'interrupted') {
|
||||
console.error('Speech synthesis error:', event.error);
|
||||
}
|
||||
globalIsSpeaking = false;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = null;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* Wix Connection Hook
|
||||
* Manages Wix connection state and operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { wixAPI, WixStatus } from '../api/wix';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
export interface WixSite {
|
||||
id: string;
|
||||
blog_url: string;
|
||||
blog_id: string;
|
||||
created_at: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
sites: WixSite[];
|
||||
total_sites: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const useWixConnection = () => {
|
||||
const { getToken } = useAuth();
|
||||
const [status, setStatus] = useState<WixStatus>({
|
||||
connected: false,
|
||||
sites: [],
|
||||
@@ -16,74 +24,50 @@ export const useWixConnection = () => {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Set up auth token getter for Wix API
|
||||
useEffect(() => {
|
||||
wixAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
if (template) {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}, [getToken]);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check sessionStorage for Wix tokens and site info
|
||||
const connectedFlag = sessionStorage.getItem('wix_connected') === 'true';
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
const siteInfoRaw = sessionStorage.getItem('wix_site_info');
|
||||
|
||||
if (connectedFlag && tokensRaw) {
|
||||
let siteInfo: any = {};
|
||||
try {
|
||||
if (siteInfoRaw) {
|
||||
siteInfo = JSON.parse(siteInfoRaw);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
try {
|
||||
const resp = await apiClient.get('/api/wix/connection/status');
|
||||
if (resp.data?.connected) {
|
||||
const siteInfo = resp.data.site_info;
|
||||
const sites: WixSite[] = siteInfo ? [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}] : [];
|
||||
setStatus({ connected: true, sites, total_sites: sites.length });
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Set connected status with site information
|
||||
setStatus({
|
||||
connected: true,
|
||||
sites: [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}],
|
||||
total_sites: 1
|
||||
});
|
||||
|
||||
const connectedFlag = sessionStorage.getItem('wix_connected') === 'true'
|
||||
|| localStorage.getItem('wix_connected') === 'true';
|
||||
|
||||
if (connectedFlag) {
|
||||
const siteInfoRaw = sessionStorage.getItem('wix_site_info');
|
||||
let siteInfo: any = {};
|
||||
try { if (siteInfoRaw) siteInfo = JSON.parse(siteInfoRaw); } catch {}
|
||||
const sites: WixSite[] = [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}];
|
||||
setStatus({ connected: true, sites, total_sites: 1 });
|
||||
} else {
|
||||
setStatus({
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: 'No Wix connection found'
|
||||
});
|
||||
setStatus({ connected: false, sites: [], total_sites: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: 'Error checking connection status'
|
||||
});
|
||||
} catch {
|
||||
setStatus({ connected: false, sites: [], total_sites: 0, error: 'Error checking connection status' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
247
frontend/src/hooks/useWixPublish.ts
Normal file
247
frontend/src/hooks/useWixPublish.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { BlogSEOMetadataResponse } from '../services/blogWriterApi';
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
}
|
||||
|
||||
export interface WixPublishResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
post_id?: string;
|
||||
message: string;
|
||||
action_required?: string;
|
||||
}
|
||||
|
||||
export function useWixPublish() {
|
||||
const [wixStatus, setWixStatus] = useState<WixStatus | null>(null);
|
||||
const [checkingWix, setCheckingWix] = useState(false);
|
||||
const [publishingWix, setPublishingWix] = useState(false);
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const pendingPublishRef = useRef<(() => Promise<WixPublishResult>) | null>(null);
|
||||
|
||||
const checkWixStatus = useCallback(async () => {
|
||||
setCheckingWix(true);
|
||||
try {
|
||||
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
|
||||
if (payload.access_token) {
|
||||
localStorage.setItem('wix_access_token', payload.access_token);
|
||||
}
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
window.name = '';
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: payload.site_info });
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiClient.get('/api/wix/connection/status');
|
||||
if (resp.data?.connected) {
|
||||
setWixStatus({
|
||||
connected: true,
|
||||
has_permissions: resp.data.has_permissions ?? true,
|
||||
site_info: resp.data.site_info,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (localStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
||||
return;
|
||||
}
|
||||
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
} catch {
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
} finally {
|
||||
setCheckingWix(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkWixStatus();
|
||||
}, [checkWixStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'wix_connected' && e.newValue === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
if (e.key === 'wix_access_token' && e.newValue) {
|
||||
setWixStatus(prev => prev ? prev : { connected: true, has_permissions: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
|
||||
const msgHandler = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'WIX_OAUTH_SUCCESS' && e.data?.success) {
|
||||
if (e.data.access_token) localStorage.setItem('wix_access_token', e.data.access_token);
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: e.data.site_info });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handler);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const publishToWix = useCallback(async (
|
||||
content: string,
|
||||
metadata: BlogSEOMetadataResponse | null,
|
||||
explicitTitle?: string,
|
||||
): Promise<WixPublishResult> => {
|
||||
const title = explicitTitle
|
||||
|| metadata?.seo_title
|
||||
|| content.match(/^#\s+(.+)$/m)?.[1]
|
||||
|| content.match(/^##\s+(.+)$/m)?.[1]?.replace(/^\d+[\.\)]\s*/, '')
|
||||
|| 'Blog Post';
|
||||
|
||||
let coverImageUrl: string | undefined;
|
||||
if (metadata?.open_graph?.image) {
|
||||
const img = metadata.open_graph.image;
|
||||
if (typeof img === 'string' && (img.startsWith('http://') || img.startsWith('https://'))) {
|
||||
coverImageUrl = img;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Include access_token as fallback. The backend DB may not have tokens
|
||||
// if the OAuth callback ran in a new tab where Clerk wasn't initialized.
|
||||
// Tokens may be in sessionStorage (same-tab) or localStorage (cross-tab).
|
||||
let accessToken: string | undefined;
|
||||
try {
|
||||
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
|
||||
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
|
||||
accessToken = payload.access_token || undefined;
|
||||
if (payload.access_token) localStorage.setItem('wix_access_token', payload.access_token);
|
||||
window.name = '';
|
||||
}
|
||||
} catch {}
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('wix_tokens');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
accessToken = parsed.accessToken?.value || parsed.access_token || undefined;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!accessToken) {
|
||||
try {
|
||||
accessToken = localStorage.getItem('wix_access_token') || undefined;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/wix/publish', {
|
||||
title,
|
||||
content,
|
||||
cover_image_url: coverImageUrl,
|
||||
category_names: metadata?.blog_categories || [],
|
||||
tag_names: metadata?.blog_tags || [],
|
||||
publish: true,
|
||||
...(accessToken ? { access_token: accessToken } : {}),
|
||||
seo_metadata: metadata ? {
|
||||
seo_title: metadata.seo_title,
|
||||
meta_description: metadata.meta_description,
|
||||
focus_keyword: metadata.focus_keyword,
|
||||
blog_tags: metadata.blog_tags || [],
|
||||
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) {
|
||||
const url = response.data.url;
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
post_id: response.data.post_id,
|
||||
message: url
|
||||
? `Blog post published to Wix! View it here: ${url}`
|
||||
: 'Blog post published successfully to Wix!',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
pendingPublishRef.current = async () => publishToWix(content, metadata);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens expired. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWixConnectionSuccess = useCallback(async () => {
|
||||
await checkWixStatus();
|
||||
const fn = pendingPublishRef.current;
|
||||
if (fn) {
|
||||
pendingPublishRef.current = null;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
setPublishingWix(true);
|
||||
await fn();
|
||||
} catch {} finally {
|
||||
setPublishingWix(false);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [checkWixStatus]);
|
||||
|
||||
const closeWixConnectModal = useCallback(() => {
|
||||
setShowWixConnectModal(false);
|
||||
pendingPublishRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
wixStatus,
|
||||
checkingWix,
|
||||
publishingWix,
|
||||
setPublishingWix,
|
||||
checkWixStatus,
|
||||
publishToWix,
|
||||
showWixConnectModal,
|
||||
setShowWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,9 @@ export interface BlogOutlineSection {
|
||||
references: ResearchSource[];
|
||||
target_words?: number;
|
||||
keywords: string[];
|
||||
chart_data?: Record<string, any>;
|
||||
chart_url?: string;
|
||||
chart_id?: string;
|
||||
}
|
||||
|
||||
export interface SourceMappingStats {
|
||||
@@ -529,6 +532,62 @@ export const blogWriterApi = {
|
||||
}
|
||||
};
|
||||
|
||||
export const saveBlogToAssetLibrary = async (params: {
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
blogType?: string;
|
||||
wordCount?: number;
|
||||
sectionCount?: number;
|
||||
model?: string;
|
||||
generationTimeMs?: number;
|
||||
}): Promise<{ assetId: number } | null> => {
|
||||
try {
|
||||
const assetMetadata = {
|
||||
blog_type: params.blogType || 'medium',
|
||||
word_count: params.wordCount,
|
||||
section_count: params.sectionCount,
|
||||
model: params.model,
|
||||
generation_time_ms: params.generationTimeMs,
|
||||
};
|
||||
|
||||
const tags = ['blog', 'ai_generated', ...(params.keywords || []).slice(0, 5)];
|
||||
|
||||
const searchResponse = await aiApiClient.get('/api/content-assets/', {
|
||||
params: {
|
||||
asset_type: 'text',
|
||||
source_module: 'blog_writer',
|
||||
search: params.title,
|
||||
limit: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const existingAsset = searchResponse.data.assets?.find(
|
||||
(asset: any) =>
|
||||
asset.asset_metadata?.blog_type &&
|
||||
asset.title === params.title
|
||||
);
|
||||
|
||||
if (existingAsset) {
|
||||
const updateResponse = await aiApiClient.put(`/api/content-assets/${existingAsset.id}`, {
|
||||
title: params.title,
|
||||
description: params.description || `Blog: ${params.title}`,
|
||||
tags,
|
||||
asset_metadata: {
|
||||
...existingAsset.asset_metadata,
|
||||
...assetMetadata,
|
||||
},
|
||||
});
|
||||
return { assetId: updateResponse.data.id };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('[blogWriterApi] saveBlogToAssetLibrary failed:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Medium blog generation (≤1000 words)
|
||||
export interface MediumSectionOutlinePayload {
|
||||
id: string;
|
||||
|
||||
@@ -95,8 +95,15 @@ class BlogWriterCacheService {
|
||||
Array.from(outlineIdsSet).every(id => cachedIds.has(id));
|
||||
|
||||
if (!idsMatch) {
|
||||
console.log('Cached content does not match outline structure');
|
||||
return null;
|
||||
// Self-heal: remap cached values to outline IDs and re-cache for future lookups
|
||||
const values: string[] = Object.values(parsedSections);
|
||||
const normalized: Record<string, string> = {};
|
||||
outlineIds.forEach((id, idx) => {
|
||||
normalized[id] = (values[idx] || '') as string;
|
||||
});
|
||||
this.cacheContent(normalized, outlineIds);
|
||||
console.log(`Cache hit for content after key normalization (${Object.keys(normalized).length} sections)`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
console.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);
|
||||
|
||||
79
frontend/src/services/chartApi.ts
Normal file
79
frontend/src/services/chartApi.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { aiApiClient, getAuthTokenGetter } from '../api/client';
|
||||
|
||||
export interface ChartGenerateRequest {
|
||||
chart_data?: Record<string, any>;
|
||||
chart_type?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
text?: string;
|
||||
section_heading?: string;
|
||||
section_key_points?: string[];
|
||||
}
|
||||
|
||||
export interface ChartGenerateResponse {
|
||||
preview_url: string;
|
||||
chart_id: string;
|
||||
chart_type?: string;
|
||||
chart_data?: Record<string, any>;
|
||||
title?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
class ChartApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
if (process.env.NODE_ENV === 'production' && !url) {
|
||||
throw new Error('REACT_APP_API_URL environment variable is required for production');
|
||||
}
|
||||
this.baseUrl = url || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
async generateChartExplicit(params: {
|
||||
chart_data: Record<string, any>;
|
||||
chart_type: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}): Promise<ChartGenerateResponse> {
|
||||
const { data } = await aiApiClient.post('/api/charts/generate', {
|
||||
chart_data: params.chart_data,
|
||||
chart_type: params.chart_type,
|
||||
title: params.title || '',
|
||||
subtitle: params.subtitle || '',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async generateChartFromText(text: string, title?: string, section_heading?: string, section_key_points?: string[]): Promise<ChartGenerateResponse> {
|
||||
const { data } = await aiApiClient.post('/api/charts/generate', {
|
||||
text,
|
||||
title: title || '',
|
||||
section_heading,
|
||||
section_key_points,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full preview URL for a chart image.
|
||||
* Appends auth token as query param so browser <img> tags can load it.
|
||||
*/
|
||||
async getPreviewUrl(previewUrl: string): Promise<string> {
|
||||
if (!previewUrl) return '';
|
||||
const fullUrl = previewUrl.startsWith('http') ? previewUrl : `${this.baseUrl}${previewUrl}`;
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (!tokenGetter) return fullUrl;
|
||||
try {
|
||||
const token = await tokenGetter();
|
||||
if (token) {
|
||||
const separator = fullUrl.includes('?') ? '&' : '?';
|
||||
return `${fullUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
} catch {}
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export const chartApi = new ChartApiService();
|
||||
export default chartApi;
|
||||
@@ -75,6 +75,7 @@ export interface HealthCheckResponse {
|
||||
|
||||
class HallucinationDetectorService {
|
||||
private baseUrl: string;
|
||||
private authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
constructor() {
|
||||
const getApiBaseUrl = () => {
|
||||
@@ -87,6 +88,21 @@ class HallucinationDetectorService {
|
||||
this.baseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
|
||||
this.authTokenGetter = getter;
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.authTokenGetter) {
|
||||
const token = await this.authTokenGetter();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect hallucinations in the provided text.
|
||||
*/
|
||||
@@ -98,9 +114,7 @@ class HallucinationDetectorService {
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
@@ -138,9 +152,7 @@ class HallucinationDetectorService {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
@@ -169,9 +181,7 @@ class HallucinationDetectorService {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
|
||||
69
frontend/src/services/linkApi.ts
Normal file
69
frontend/src/services/linkApi.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { aiApiClient } from '../api/client';
|
||||
|
||||
export interface LinkSearchRequest {
|
||||
query: string;
|
||||
link_type: 'internal' | 'external';
|
||||
site_url?: string;
|
||||
num_results?: number;
|
||||
}
|
||||
|
||||
export interface LinkSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
text: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface LinkSearchResponse {
|
||||
results: LinkSearchResult[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface RewordRequest {
|
||||
section_text: string;
|
||||
selected_text?: string;
|
||||
section_heading?: string;
|
||||
links: Array<{ url: string; title: string }>;
|
||||
}
|
||||
|
||||
export interface RewordResponse {
|
||||
reworded_text: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
class LinkApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
if (process.env.NODE_ENV === 'production' && !url) {
|
||||
throw new Error('REACT_APP_API_URL environment variable is required for production');
|
||||
}
|
||||
this.baseUrl = url || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
async searchLinks(params: LinkSearchRequest): Promise<LinkSearchResponse> {
|
||||
const { data } = await aiApiClient.post('/api/links/search', {
|
||||
query: params.query,
|
||||
link_type: params.link_type,
|
||||
site_url: params.site_url || '',
|
||||
num_results: params.num_results || 5,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async rewordWithLinks(params: RewordRequest): Promise<RewordResponse> {
|
||||
const { data } = await aiApiClient.post('/api/links/reword', {
|
||||
section_text: params.section_text,
|
||||
selected_text: params.selected_text,
|
||||
section_heading: params.section_heading,
|
||||
links: params.links,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const linkApi = new LinkApiService();
|
||||
export default linkApi;
|
||||
@@ -20,6 +20,7 @@ export interface WASuggestResponse {
|
||||
|
||||
class WritingAssistantService {
|
||||
private baseUrl: string;
|
||||
private authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
constructor() {
|
||||
const getApiBaseUrl = () => {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
@@ -31,10 +32,25 @@ class WritingAssistantService {
|
||||
this.baseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
|
||||
this.authTokenGetter = getter;
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.authTokenGetter) {
|
||||
const token = await this.authTokenGetter();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async suggest(text: string): Promise<WASuggestion[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/writing-assistant/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify({ text, max_results: 1 })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -418,4 +418,25 @@ html.blog-writer-page {
|
||||
|
||||
.blog-writer-container .MuiTextField-root .MuiOutlinedInput-root fieldset {
|
||||
border-color: rgba(26, 26, 26, 0.23) !important;
|
||||
}
|
||||
|
||||
/* Hide CopilotKit Web Inspector button and announcement globally */
|
||||
cpk-web-inspector {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
position: absolute !important;
|
||||
left: -9999px !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
[class*="copilotkit"] [class*="announcement"],
|
||||
[class*="copilotkit"] [class*="announce"],
|
||||
.announcement-preview,
|
||||
[data-announcement],
|
||||
.cpk-announcement {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Wix Token Utilities
|
||||
* Functions for validating and refreshing Wix OAuth tokens
|
||||
*/
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
interface WixTokens {
|
||||
accessToken?: {
|
||||
value: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
refreshToken?: {
|
||||
value: string;
|
||||
};
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
interface TokenValidationResult {
|
||||
valid: boolean;
|
||||
accessToken: string | null;
|
||||
needsRefresh: boolean;
|
||||
needsReconnect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Wix tokens from sessionStorage
|
||||
*/
|
||||
export function getWixTokens(): WixTokens | null {
|
||||
try {
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) return null;
|
||||
return JSON.parse(tokensRaw);
|
||||
} catch (error) {
|
||||
console.error('Error parsing Wix tokens:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract access token from token structure
|
||||
*/
|
||||
export function extractAccessToken(tokens: WixTokens | null): string | null {
|
||||
if (!tokens) return null;
|
||||
return tokens.accessToken?.value || tokens.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract refresh token from token structure
|
||||
*/
|
||||
export function extractRefreshToken(tokens: WixTokens | null): string | null {
|
||||
if (!tokens) return null;
|
||||
return tokens.refreshToken?.value || tokens.refresh_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Wix access token using refresh token
|
||||
*/
|
||||
export async function refreshWixToken(refreshToken: string): Promise<WixTokens | null> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/refresh-token', {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Create new token structure matching Wix SDK format
|
||||
const newTokens: WixTokens = {
|
||||
accessToken: {
|
||||
value: response.data.access_token
|
||||
},
|
||||
refreshToken: {
|
||||
value: response.data.refresh_token || refreshToken // Keep old refresh token if new one not provided
|
||||
},
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token || refreshToken
|
||||
};
|
||||
|
||||
// Update sessionStorage
|
||||
try {
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(newTokens));
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
} catch (e) {
|
||||
console.error('Error saving refreshed tokens:', e);
|
||||
}
|
||||
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('Error refreshing Wix token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired based on expiresAt timestamp
|
||||
*/
|
||||
function isTokenExpired(tokens: WixTokens): boolean {
|
||||
if (tokens.accessToken?.expiresAt) {
|
||||
try {
|
||||
const expiresAt = new Date(tokens.accessToken.expiresAt);
|
||||
return expiresAt < new Date();
|
||||
} catch (e) {
|
||||
// If we can't parse, assume not expired (will validate during publish)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// If no expiration info, we can't tell - assume valid for now
|
||||
// Real validation happens during actual API call
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and refresh Wix tokens proactively
|
||||
* Returns access token if valid, or null if needs reconnection
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Check if tokens exist
|
||||
* 2. Check if token is expired (if expiration info available)
|
||||
* 3. If expired, attempt refresh
|
||||
* 4. If refresh fails or no refresh token, needs reconnection
|
||||
* 5. Real validation happens during actual publish (we catch 401/403 errors)
|
||||
*/
|
||||
export async function validateAndRefreshWixTokens(): Promise<TokenValidationResult> {
|
||||
const tokens = getWixTokens();
|
||||
|
||||
if (!tokens) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = extractAccessToken(tokens);
|
||||
const refreshToken = extractRefreshToken(tokens);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired (if we have expiration info)
|
||||
const expired = isTokenExpired(tokens);
|
||||
|
||||
if (!expired) {
|
||||
// Token appears valid (not expired or no expiration info)
|
||||
// We'll do real validation during publish
|
||||
return {
|
||||
valid: true,
|
||||
accessToken: accessToken,
|
||||
needsRefresh: false,
|
||||
needsReconnect: false
|
||||
};
|
||||
}
|
||||
|
||||
// Token is expired, try to refresh
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
// Attempt to refresh token
|
||||
const refreshedTokens = await refreshWixToken(refreshToken);
|
||||
|
||||
if (refreshedTokens) {
|
||||
const newAccessToken = extractAccessToken(refreshedTokens);
|
||||
if (newAccessToken) {
|
||||
return {
|
||||
valid: true,
|
||||
accessToken: newAccessToken,
|
||||
needsRefresh: true,
|
||||
needsReconnect: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed, needs reconnection
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user