Files
ALwrity/frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
ajaysi 923fa671fe feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py

Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state

Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed

Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
2026-06-01 12:24:31 +05:30

795 lines
32 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import {
Paper,
IconButton,
Chip,
TextField,
Tooltip,
CircularProgress,
Divider,
Box
} from '@mui/material';
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
FileCopyOutlined as FileCopyOutlinedIcon,
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;
title: string;
content: string;
wordCount: number;
sources: number;
outlineData?: {
subheadings: string[];
keyPoints: string[];
keywords: string[];
references: any[];
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> = ({
id,
title,
content: initialContent,
sources,
outlineData,
onContentUpdate,
onDeleteSection,
expandedSections,
toggleSectionExpansion,
refreshToken,
flowAnalysisResults,
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);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const [toolsAnchorEl, setToolsAnchorEl] = useState<HTMLElement | null>(null);
const [activeTool, setActiveTool] = useState<null | 'originality' | 'optimize' | 'fact' | 'links' | 'flow'>(null);
const [toolLoading, setToolLoading] = useState(false);
const [toolResult, setToolResult] = useState<any>(null);
const [toolDialogOpen, setToolDialogOpen] = useState(false);
const wordCount_ = useMemo(() => content.split(/\s+/).filter(Boolean).length, [content]);
const handleFormatText = useCallback((formatType: string, startPos?: number, endPos?: number) => {
const textarea = contentRef.current;
if (!textarea) return;
const start = startPos ?? textarea.selectionStart;
const end = endPos ?? textarea.selectionEnd;
const selected = content.substring(start, end);
const trimmed = selected.trim();
let replacement: string;
let cursorPos: number;
switch (formatType) {
case 'bold': {
const outerMatch = trimmed.match(/^\*\*(.+)\*\*$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `**${trimmed.replace(/\*\*/g, '')}**`;
}
cursorPos = start + replacement.length;
break;
}
case 'italic': {
const outerMatch = trimmed.match(/^\*(?!\*)(.+)(?<!\*)\*$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `*${trimmed.replace(/\*/g, '')}*`;
}
cursorPos = start + replacement.length;
break;
}
case 'link': {
replacement = trimmed ? `[${trimmed}](url)` : `[text](url)`;
cursorPos = trimmed ? start + replacement.length - 5 : start + 1;
break;
}
case 'heading-2': {
replacement = trimmed ? `## ${trimmed}` : `## Heading`;
cursorPos = start + replacement.length;
break;
}
case 'heading-3': {
replacement = trimmed ? `### ${trimmed}` : `### Heading`;
cursorPos = start + replacement.length;
break;
}
case 'bullet-list': {
replacement = trimmed ? `- ${trimmed}` : `- List item`;
cursorPos = start + replacement.length;
break;
}
case 'numbered-list': {
replacement = trimmed ? `1. ${trimmed}` : `1. List item`;
cursorPos = start + replacement.length;
break;
}
case 'blockquote': {
replacement = trimmed ? `> ${trimmed}` : `> Quote`;
cursorPos = start + replacement.length;
break;
}
case 'code': {
const outerMatch = trimmed.match(/^`(.+)`$/s);
if (outerMatch) {
replacement = outerMatch[1];
} else {
replacement = `\`${trimmed.replace(/`/g, '')}\``;
}
cursorPos = start + replacement.length;
break;
}
case 'hr': {
replacement = `\n\n---\n\n`;
cursorPos = start + replacement.length;
break;
}
default:
return;
}
const newContent = content.substring(0, start) + replacement + content.substring(end);
setContent(newContent);
if (onContentUpdate) onContentUpdate([{ id, content: newContent }]);
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: { originalText: selected, editedText: replacement, editType: 'format' }
}));
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(cursorPos, cursorPos);
});
}, [content, id, onContentUpdate]);
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
if (contentRef.current) {
const textarea = contentRef.current;
let updatedContent: string;
if (editType === 'smart-suggestion') {
updatedContent = newText;
} else {
updatedContent = textarea.value.replace(originalText, newText);
}
setContent(updatedContent);
if (onContentUpdate) onContentUpdate([{ id, content: updatedContent }]);
}
},
handleFormatText
);
const formatContent = (rawContent: string) => {
if (!rawContent) return rawContent;
return rawContent.replace(/\n{3,}/g, '\n\n').replace(/\n(?!\n)/g, '\n\n').trim();
};
useEffect(() => {
if (initialContent !== content) {
setContent(formatContent(initialContent));
}
}, [initialContent]);
const handleContentChange = (e: any) => {
const newContent = e.target.value;
const cursorPos = e.target.selectionStart;
setContent(newContent);
assistiveWriting.handleTypingChange(newContent, cursorPos);
};
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const closeToolDialog = () => {
setToolDialogOpen(false);
setToolLoading(false);
};
const runSectionTool = useCallback(async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
setActiveTool(tool);
setToolResult(null);
setToolLoading(true);
setToolDialogOpen(true);
try {
let res;
if (tool === 'originality') {
res = await blogWriterApi.sectionOriginalityTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'links') {
res = await blogWriterApi.sectionInternalLinkTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'fact') {
res = await blogWriterApi.sectionFactCheckTools({ section_id: String(id), title: sectionTitle, content });
} else if (tool === 'optimize') {
res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id), title: sectionTitle, content,
keywords: outlineData?.keywords || [], goal: 'readability'
});
} else if (tool === 'flow') {
res = await blogWriterApi.analyzeFlowAdvanced({
title: sectionTitle,
sections: [{ id: String(id), heading: sectionTitle, content }]
});
}
setToolResult(res);
} catch (error: any) {
setToolResult({ success: false, error: error?.message || 'Request failed' });
} finally {
setToolLoading(false);
}
}, [id, sectionTitle, content, outlineData]);
const applyOptimizedContent = () => {
const next = toolResult?.optimized_content;
if (!next) return;
setContent(next);
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
closeToolDialog();
};
const insertLinkSuggestion = (url: string) => {
if (!url) return;
const next = `${content || ''}\n\n[Related](${url})`;
setContent(next);
if (onContentUpdate) onContentUpdate([{ id, content: next }]);
};
const handleGenerateContent = async () => {
setIsGenerating(true);
await new Promise(resolve => setTimeout(resolve, 2000));
setIsGenerating(false);
};
// HoverMenu action handler
const handleSectionAction = useCallback((action: string) => {
switch (action) {
case 'generate-content':
handleGenerateContent();
break;
case 'enhance-section':
runSectionTool('optimize');
break;
case 'fact-check':
runSectionTool('fact');
break;
case 'source-mapping':
runSectionTool('originality');
break;
case 'seo-analysis':
runSectionTool('flow');
break;
case 'add-subsection':
break;
case 'copy-section':
break;
case 'delete-section':
break;
default:
break;
}
}, []);
return (
<div
className="group relative mb-8"
id={`section-${id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs font-medium text-gray-300 select-none">{id}.</span>
{isEditing ? (
<TextField
fullWidth
variant="standard"
value={sectionTitle}
onChange={(e) => setSectionTitle(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === 'Escape') setIsEditing(false); }}
autoFocus
InputProps={{ disableUnderline: true, className: 'text-xl md:text-2xl font-bold font-serif text-gray-800' }}
/>
) : (
<h2
className="flex-1 text-xl md:text-2xl font-bold font-serif text-gray-800 cursor-text hover:text-indigo-600 transition-colors duration-150"
onClick={() => setIsEditing(true)}
>
{sectionTitle}
</h2>
)}
{/* 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 && (
<div className="mb-4">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white max-w-full mx-auto" style={{ maxWidth: 'min(100%, 720px)' }}>
<img
src={sectionImage.startsWith('http') || sectionImage.startsWith('/api/') ? sectionImage : `data:image/png;base64,${sectionImage}`}
alt={`Image for ${sectionTitle}`}
className="block w-full max-w-full h-auto max-h-96 object-contain mx-auto"
/>
</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 },
'& h3': { fontSize: '1.25rem', fontWeight: 600 },
'& h4': { fontSize: '1.15rem', fontWeight: 600, color: '#1e293b', mt: 1.5, mb: 0.5 },
'& h5, & h6': { fontSize: '1rem', fontWeight: 600, color: '#334155', mt: 1.5, mb: 0.5 },
'& 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',
},
'& kbd': {
bgcolor: '#f1f5f9',
border: '1px solid #d1d5db',
borderRadius: '4px',
px: 1,
py: 0.25,
fontFamily: 'monospace',
fontSize: '0.85em',
boxShadow: '0 1px 0 #d1d5db',
},
'& mark': { bgcolor: '#fef3c7', color: '#92400e', px: 0.5, borderRadius: 0.25 },
'& sub, & sup': { fontSize: '0.75em', lineHeight: 1 },
'& details': { mb: 1.5 },
'& details summary': { cursor: 'pointer', fontWeight: 600, color: '#1e293b' },
'& details summary:hover': { color: '#4f46e5' },
'& dl': { mb: 1.5 },
'& dl dt': { fontWeight: 600, color: '#1e293b', mt: 1 },
'& dl dd': { ml: 2, color: '#4b5563' },
'& abbr': { cursor: 'help', textDecoration: 'underline dotted #94a3b8' },
'& ul, & ol': { pl: 2, mb: 1.5 },
'& li': { mb: 0.5 },
'& hr': { borderColor: '#e5e7eb', my: 2 },
'& img': { maxWidth: '100%', height: 'auto', borderRadius: 1 },
'& table': { borderCollapse: 'collapse', width: '100%', mb: 2, fontSize: '0.95rem' },
'& th, & td': { border: '1px solid #d1d5db', px: 2, py: 1, textAlign: 'left' },
'& th': { bgcolor: '#f3f4f6', fontWeight: 600 },
'& tr:nth-of-type(even)': { bgcolor: '#f9fafb' },
'& .table-wrapper': { overflowX: 'auto', mb: 2 },
'& .table-wrapper table': { mb: 0 },
'& pre': { bgcolor: '#1e293b', color: '#e2e8f0', p: 2.5, borderRadius: 1, overflowX: 'auto', fontFamily: 'monospace', fontSize: '0.875rem', lineHeight: 1.5, mb: 2 },
'& pre code': { bgcolor: 'transparent', color: 'inherit', p: 0, fontSize: 'inherit', lineHeight: 'inherit' },
'& del': { color: '#991b1b', textDecoration: 'line-through' },
'& input[type="checkbox"]': { mr: 1, transform: 'scale(1.1)', accentColor: '#4f46e5' },
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(content) }}
/>
</div>
) : (
// Edit Mode
<div className="relative">
<TextField
multiline
fullWidth
variant="outlined"
placeholder="Start writing... Use the toolbar above to format text, or type markdown directly."
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelect={assistiveWriting.handleTextSelection}
inputRef={contentRef}
minRows={5}
InputProps={{
className: `font-serif text-base leading-relaxed text-gray-700 ${isFocused ? 'bg-white' : 'bg-gray-50/30'}`,
style: { lineHeight: '1.8', padding: '12px 16px' },
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': {
border: '1px solid #e2e8f0',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
'& .MuiOutlinedInput-root': { padding: 0 },
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#4f46e5', borderWidth: 2 },
'& .MuiInputBase-input': { padding: '12px 16px !important' },
}}
/>
</div>
)}
{/* Outline info */}
{outlineData && expandedSections.has(id) && (
<div className="mt-3 mb-2">
<Paper elevation={0} sx={{ p: 3, bgcolor: '#f8f9fa', borderRadius: 2, border: '1px solid #f0f0f0' }}>
<div className="grid grid-cols-2 gap-4">
{outlineData.keyPoints?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Key Points</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keyPoints.map((point: any, i: any) => (
<Chip key={i} label={point} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{outlineData.subheadings?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Subheadings</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.subheadings.map((sub: any, i: any) => (
<Chip key={i} label={sub} size="small" variant="outlined" color="secondary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{outlineData.targetWords > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Target words</div>
<div className="text-sm text-gray-700">{outlineData.targetWords}</div>
</div>
)}
{outlineData.keywords?.length > 0 && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Keywords</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.keywords.map((kw: any, i: any) => (
<Chip key={i} label={kw} size="small" variant="filled" color="primary" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
</div>
</div>
)}
{outlineData.references?.length > 0 && (
<div className="col-span-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
References ({outlineData.references.length})
</div>
<div className="flex flex-wrap gap-1.5">
{outlineData.references.slice(0, 3).map((ref: any, i: any) => (
<Chip key={i} label={ref.title || `Source ${i + 1}`} size="small" variant="outlined" color="info" sx={{ fontSize: '0.7rem', height: 24 }} />
))}
{outlineData.references.length > 3 && (
<Chip label={`+${outlineData.references.length - 3} more`} size="small" variant="outlined" sx={{ fontSize: '0.7rem', height: 24 }} />
)}
</div>
</div>
)}
</div>
</Paper>
</div>
)}
{/* 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" 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" style={{
fontWeight: 600,
color: wordCount_ >= outlineData.targetWords * 0.9 ? '#10b981' : '#94a3b8',
}}>
{outlineData.targetWords} target
</span>
</>
)}
</div>
<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,
bgcolor: 'transparent',
color: '#64748b',
'&:hover': {
bgcolor: '#f1f5f9',
},
}}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 14 }} /> : <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
</div>
</div>
{/* HoverMenu for section-level actions */}
<HoverMenu
anchorEl={toolsAnchorEl}
open={Boolean(toolsAnchorEl)}
onClose={() => setToolsAnchorEl(null)}
type="section"
onAction={handleSectionAction}
context={{
sectionId: String(id),
hasContent: content.trim().length > 0,
sources,
wordCount: wordCount_,
}}
/>
{/* Tool result dialog */}
{toolDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20" onClick={closeToolDialog}>
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-800">
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</h3>
</div>
<div className="px-6 py-4 overflow-y-auto flex-1">
{toolLoading && (
<div className="flex items-center gap-3">
<CircularProgress size={18} />
<span className="text-sm text-gray-500">Working...</span>
</div>
)}
{!toolLoading && toolResult?.error && (
<div className="text-red-600 font-medium">{toolResult.error}</div>
)}
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div className="space-y-3">
{toolResult?.diff_summary && <p className="font-medium">{toolResult.diff_summary}</p>}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{toolResult.changes_made.map((c: string, idx: number) => (
<li key={idx} className="text-sm text-gray-600">{c}</li>
))}
</ul>
)}
<TextField multiline minRows={10} value={toolResult.optimized_content} fullWidth InputProps={{ readOnly: true }} />
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div className="space-y-2">
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
toolResult.suggestions.map((s: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 truncate">{s.url}</p>
<p className="text-xs text-gray-500">confidence: {(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}</p>
</div>
<button onClick={() => insertLinkSuggestion(s.url)} className="text-sm text-indigo-600 hover:text-indigo-800 ml-3 shrink-0">Insert</button>
</div>
))
) : (
<p className="text-sm text-gray-500">No suggestions yet. Make sure SIF index has your website content.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div className="space-y-3">
{toolResult?.cannibalization && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<div className="space-y-2">
{toolResult.matches.map((m: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">{m.id ?? 'unknown'} ({(m.score ?? 0).toFixed?.(3) ?? m.score})</p>
{m.excerpt && <p className="text-xs text-gray-500 mt-1">{m.excerpt}</p>}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No close matches found.</p>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div className="space-y-3">
{toolResult?.verification && <pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult.verification, null, 2)}</pre>}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<div className="space-y-2">
{toolResult.citations.map((c: any, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm">{c.citation_text || c.title || c.source}</p>
<p className="text-xs text-gray-500">{c.source}</p>
</div>
))}
</div>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre className="text-sm whitespace-pre-wrap">{JSON.stringify(toolResult, null, 2)}</pre>
)}
</div>
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
<button onClick={closeToolDialog} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors">Close</button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<button onClick={applyOptimizedContent} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors">Replace Section Content</button>
)}
</div>
</div>
</div>
)}
<Divider sx={{ mt: 2, opacity: 0.2 }} />
{assistiveWriting.renderSelectionMenu()}
</div>
);
};
export default BlogSection;