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
This commit is contained in:
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -45,6 +45,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "^25.0.10",
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"typescript": "^5.3.3"
|
||||
@@ -7236,6 +7237,13 @@
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/marked": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
|
||||
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -12064,15 +12072,6 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
|
||||
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
|
||||
@@ -28629,6 +28628,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/uvu/node_modules/diff": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
|
||||
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/uvu/node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "^25.0.10",
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -111,12 +111,7 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '1.125rem',
|
||||
lineHeight: 1.8,
|
||||
color: '#475569',
|
||||
}}
|
||||
className="rendered-content-intro"
|
||||
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(introduction) }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -151,12 +146,7 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
|
||||
|
||||
{/* Section Content */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.8,
|
||||
color: '#334155',
|
||||
}}
|
||||
className="rendered-content"
|
||||
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(section.content) }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -189,15 +179,150 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
{/* Print Styles */}
|
||||
{/* Rendered Content Styles + Print Styles */}
|
||||
<style>{`
|
||||
.rendered-content {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: #334155;
|
||||
}
|
||||
.rendered-content h1, .rendered-content h2, .rendered-content h3 {
|
||||
color: #111827;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rendered-content h1 { font-size: 2rem; font-weight: 700; }
|
||||
.rendered-content h2 { font-size: 1.5rem; font-weight: 600; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; }
|
||||
.rendered-content h3 { font-size: 1.25rem; font-weight: 600; }
|
||||
.rendered-content h4 { font-size: 1.15rem; font-weight: 600; color: #1e293b; margin-top: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.rendered-content h5, .rendered-content h6 { font-size: 1rem; font-weight: 600; color: #334155; margin-top: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.rendered-content p { margin-bottom: 0.75rem; }
|
||||
.rendered-content strong { font-weight: 600; }
|
||||
.rendered-content em { font-style: italic; }
|
||||
.rendered-content a { color: #4f46e5; text-decoration: underline; }
|
||||
.rendered-content blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.rendered-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.rendered-content kbd {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
box-shadow: 0 1px 0 #d1d5db;
|
||||
}
|
||||
.rendered-content mark { background: #fef3c7; color: #92400e; padding: 0 4px; border-radius: 2px; }
|
||||
.rendered-content sub, .rendered-content sup { font-size: 0.75em; line-height: 1; }
|
||||
.rendered-content details { margin-bottom: 0.75rem; }
|
||||
.rendered-content details summary { cursor: pointer; font-weight: 600; color: #1e293b; }
|
||||
.rendered-content details summary:hover { color: #4f46e5; }
|
||||
.rendered-content dl { margin-bottom: 0.75rem; }
|
||||
.rendered-content dl dt { font-weight: 600; color: #1e293b; margin-top: 0.5rem; }
|
||||
.rendered-content dl dd { margin-left: 1rem; color: #4b5563; }
|
||||
.rendered-content abbr { cursor: help; text-decoration: underline dotted #94a3b8; }
|
||||
.rendered-content ul, .rendered-content ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.rendered-content li { margin-bottom: 0.25rem; }
|
||||
.rendered-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.5rem 0; }
|
||||
.rendered-content img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
|
||||
.rendered-content .table-wrapper { overflow-x: auto; margin-bottom: 1rem; }
|
||||
.rendered-content .table-wrapper table { margin-bottom: 0; }
|
||||
|
||||
.rendered-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.rendered-content th, .rendered-content td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.rendered-content th { background: #f3f4f6; font-weight: 600; }
|
||||
.rendered-content tr:nth-of-type(even) { background: #f9fafb; }
|
||||
|
||||
.rendered-content pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.rendered-content pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.rendered-content del { color: #991b1b; text-decoration: line-through; }
|
||||
.rendered-content input[type="checkbox"] { margin-right: 0.5rem; transform: scale(1.1); accent-color: #4f46e5; }
|
||||
|
||||
.rendered-content-intro {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: #475569;
|
||||
}
|
||||
.rendered-content-intro .table-wrapper { overflow-x: auto; margin-bottom: 1rem; }
|
||||
.rendered-content-intro .table-wrapper table { margin-bottom: 0; }
|
||||
.rendered-content-intro table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.rendered-content-intro th, .rendered-content-intro td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.rendered-content-intro th { background: #f3f4f6; font-weight: 600; }
|
||||
.rendered-content-intro tr:nth-of-type(even) { background: #f9fafb; }
|
||||
.rendered-content-intro pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; font-family: monospace; font-size: 0.875rem; line-height: 1.5; margin: 1rem 0; }
|
||||
.rendered-content-intro pre code { background: transparent; color: inherit; padding: 0; font-size: inherit; line-height: inherit; }
|
||||
.rendered-content-intro code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.9em; }
|
||||
.rendered-content-intro a { color: #4f46e5; text-decoration: underline; }
|
||||
.rendered-content-intro blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.rendered-content-intro ul, .rendered-content-intro ol { padding-left: 1.5rem; margin-bottom: 0.75rem; }
|
||||
.rendered-content-intro li { margin-bottom: 0.25rem; }
|
||||
.rendered-content-intro img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
.rendered-content-intro del { color: #991b1b; text-decoration: line-through; }
|
||||
.rendered-content-intro input[type="checkbox"] { margin-right: 0.5rem; transform: scale(1.1); accent-color: #4f46e5; }
|
||||
.rendered-content-intro h1, .rendered-content-intro h2, .rendered-content-intro h3, .rendered-content-intro h4, .rendered-content-intro h5, .rendered-content-intro h6 { color: #111827; }
|
||||
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.MuiDialogContent-root, .MuiDialogContent-root * {
|
||||
visibility: visible;
|
||||
}
|
||||
body * { visibility: hidden; }
|
||||
.MuiDialogContent-root, .MuiDialogContent-root * { visibility: visible; }
|
||||
.MuiDialogContent-root {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -207,19 +332,11 @@ export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
|
||||
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;
|
||||
}
|
||||
.MuiDialog-paper > div:last-child { display: none !important; }
|
||||
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
|
||||
img { max-width: 100% !important; page-break-inside: avoid; }
|
||||
pre, table { page-break-inside: avoid; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SEOProcessor } from './SEO';
|
||||
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
||||
import { DiffPreviewModal } from './DiffPreviewModal/DiffPreviewModal';
|
||||
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
|
||||
import HeaderBar from './BlogWriterUtils/HeaderBar';
|
||||
import PhaseContent from './BlogWriterUtils/PhaseContent';
|
||||
@@ -65,6 +66,7 @@ const BlogWriter: React.FC = () => {
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
sections,
|
||||
introduction,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
continuityRefresh,
|
||||
@@ -84,6 +86,7 @@ const BlogWriter: React.FC = () => {
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setIntroduction,
|
||||
setSeoAnalysis,
|
||||
setSeoMetadata,
|
||||
setContinuityRefresh,
|
||||
@@ -134,8 +137,13 @@ const BlogWriter: React.FC = () => {
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
isDiffModalOpen,
|
||||
diffPreviewData,
|
||||
acceptDiffChanges,
|
||||
rejectDiffChanges,
|
||||
} = useSEOManager({
|
||||
sections,
|
||||
introduction,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
@@ -148,6 +156,7 @@ const BlogWriter: React.FC = () => {
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle: setSelectedTitle as (title: string | null) => void,
|
||||
setIntroduction,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
@@ -497,6 +506,7 @@ const BlogWriter: React.FC = () => {
|
||||
localStorage.removeItem('blogwriter_user_selected_phase');
|
||||
localStorage.removeItem('blog_content_confirmed');
|
||||
localStorage.removeItem('blog_seo_recommendations_applied');
|
||||
localStorage.removeItem('blog_publish_completed');
|
||||
localStorage.removeItem('blog_last_asset_id');
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
@@ -725,7 +735,7 @@ const BlogWriter: React.FC = () => {
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={hasContent}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
hasSEOAnalysis={!!seoAnalysis && (seoRecommendationsApplied || !!seoMetadata)}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
onNewBlog={confirmNewBlog}
|
||||
@@ -764,6 +774,8 @@ const BlogWriter: React.FC = () => {
|
||||
researchCoverage={researchCoverage}
|
||||
setOutline={setOutline}
|
||||
sections={sections}
|
||||
introduction={introduction}
|
||||
onIntroductionUpdate={setIntroduction}
|
||||
handleContentUpdate={handleContentUpdate}
|
||||
handleContentSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
@@ -827,6 +839,14 @@ const BlogWriter: React.FC = () => {
|
||||
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* Diff Preview Modal */}
|
||||
<DiffPreviewModal
|
||||
isOpen={isDiffModalOpen}
|
||||
diffData={diffPreviewData}
|
||||
onAccept={acceptDiffChanges}
|
||||
onReject={rejectDiffChanges}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
<SEOMetadataModal
|
||||
isOpen={isSEOMetadataModalOpen}
|
||||
|
||||
@@ -50,6 +50,8 @@ interface PhaseContentProps {
|
||||
onAngleSelect?: (angle: string) => void;
|
||||
selectedCompetitiveAdvantage?: string;
|
||||
onCompetitiveAdvantageSelect?: (advantage: string) => void;
|
||||
introduction?: string;
|
||||
onIntroductionUpdate?: (intro: string) => void;
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
@@ -95,6 +97,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
onAngleSelect,
|
||||
selectedCompetitiveAdvantage,
|
||||
onCompetitiveAdvantageSelect,
|
||||
introduction,
|
||||
onIntroductionUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
@@ -173,8 +177,10 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
introduction={introduction}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
onIntroductionUpdate={onIntroductionUpdate}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
@@ -199,7 +205,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
|
||||
<>
|
||||
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
|
||||
@@ -211,8 +217,10 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
introduction={introduction}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
onIntroductionUpdate={onIntroductionUpdate}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../a
|
||||
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
|
||||
import WixConnectModal from './WixConnectModal';
|
||||
import { useWixPublish } from '../../../hooks/useWixPublish';
|
||||
import { useTextToSpeech } from '../../../hooks/useTextToSpeech';
|
||||
|
||||
const saveCompleteBlogAsset = async (
|
||||
title: string,
|
||||
@@ -48,6 +49,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
setShowWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
validateWixContent,
|
||||
} = useWixPublish();
|
||||
|
||||
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
|
||||
@@ -55,6 +57,39 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
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);
|
||||
const [wixContentWarning, setWixContentWarning] = useState<string | null>(null);
|
||||
|
||||
// Audio / TTS
|
||||
const { speak, stop, isSpeaking, isSupported } = useTextToSpeech();
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
|
||||
const stripMarkdown = (md: string) => {
|
||||
return md
|
||||
.replace(/[#*_~`]/g, '')
|
||||
.replace(/\[(.*?)\]\(.*\)/g, '$1')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const handleListen = () => {
|
||||
if (isSpeaking) {
|
||||
stop();
|
||||
setIsListening(false);
|
||||
return;
|
||||
}
|
||||
const md = buildFullMarkdown();
|
||||
const plainText = stripMarkdown(md);
|
||||
if (!plainText) return;
|
||||
setIsListening(true);
|
||||
speak(plainText, { rate: 1 });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening && !isSpeaking) {
|
||||
setIsListening(false);
|
||||
}
|
||||
}, [isSpeaking, isListening]);
|
||||
|
||||
useEffect(() => {
|
||||
checkWPStatus();
|
||||
@@ -105,6 +140,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
const result = await wordpressAPI.publishContent(request);
|
||||
if (result.success) {
|
||||
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
|
||||
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
|
||||
} else {
|
||||
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
|
||||
}
|
||||
@@ -118,10 +154,24 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
const handlePublishToWix = async () => {
|
||||
const md = buildFullMarkdown();
|
||||
setPublishResult(null);
|
||||
setWixContentWarning(null);
|
||||
const validation = validateWixContent(md);
|
||||
if (!validation.valid) {
|
||||
setPublishResult({ platform: 'wix', success: false, message: validation.warning || 'Content validation failed.' });
|
||||
return;
|
||||
}
|
||||
if (validation.warning) {
|
||||
setWixContentWarning(validation.warning);
|
||||
}
|
||||
const result = await publishToWix(md, seoMetadata, blogTitle);
|
||||
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
|
||||
if (result.warning && result.success) {
|
||||
setWixContentWarning(result.warning);
|
||||
}
|
||||
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
|
||||
if (result.success) {
|
||||
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
|
||||
try { localStorage.setItem('blog_publish_completed', 'true'); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +277,11 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
Site: {wixStatus.site_info.name || wixStatus.site_info.displayName}
|
||||
</div>
|
||||
)}
|
||||
{wixContentWarning && (
|
||||
<div style={{ marginTop: 8, padding: '6px 10px', fontSize: '0.8rem', color: '#92400e', background: '#fef3c7', borderRadius: 6, border: '1px solid #fcd34d' }}>
|
||||
{wixContentWarning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export card */}
|
||||
@@ -235,7 +290,7 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
<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 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
|
||||
@@ -248,6 +303,19 @@ export const PublishContent: React.FC<PublishContentProps> = ({
|
||||
>
|
||||
{copyDone ? 'Copied!' : 'Copy HTML'}
|
||||
</button>
|
||||
{isSupported && (
|
||||
<button
|
||||
onClick={handleListen}
|
||||
style={{
|
||||
...btnStyle,
|
||||
background: isListening ? '#fef2f2' : '#f1f5f9',
|
||||
color: isListening ? '#991b1b' : '#334155',
|
||||
border: `1px solid ${isListening ? '#fecaca' : '#e2e8f0'}`,
|
||||
}}
|
||||
>
|
||||
{isListening ? '🔊 Stop Listening' : '🔈 Listen to Blog'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { usePlatformConnections } from '../../../components/OnboardingWizard/common/usePlatformConnections';
|
||||
import { getWixTrustedOrigins } from '../../../config/wixConfig';
|
||||
import { markConnectionHandled, isAlreadyHandled } from '../../../utils/wixConnectionDedup';
|
||||
|
||||
interface WixConnectModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,12 +34,13 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const trusted = [window.location.origin, ngrokOrigin];
|
||||
const trusted = getWixTrustedOrigins();
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
if (isAlreadyHandled()) return;
|
||||
markConnectionHandled();
|
||||
console.log('Wix OAuth success in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
@@ -65,6 +68,8 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
if (isAlreadyHandled()) return;
|
||||
markConnectionHandled();
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
@@ -81,6 +86,8 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'wix_connected' && e.newValue === 'true') {
|
||||
if (isAlreadyHandled()) return;
|
||||
markConnectionHandled();
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
|
||||
@@ -42,11 +42,15 @@ export const usePhaseRestoration = ({
|
||||
const restoredPhase = localStorage.getItem('blogwriter_current_phase');
|
||||
const userSelectedPhase = localStorage.getItem('blogwriter_user_selected_phase') === 'true';
|
||||
|
||||
// Determine if we should restore based on user selection OR publish completion
|
||||
const publishCompleted = localStorage.getItem('blog_publish_completed') === 'true';
|
||||
const shouldRestore = restoredPhase && restoredPhase !== currentPhase && (userSelectedPhase || publishCompleted);
|
||||
|
||||
// Only restore if:
|
||||
// 1. A phase was saved (restoredPhase exists)
|
||||
// 2. User had manually selected a phase (indicates they were actively working)
|
||||
// 2. User had manually selected a phase OR publish was completed (indicates they were actively working)
|
||||
// 3. The phase is different from current (to avoid unnecessary updates)
|
||||
if (restoredPhase && userSelectedPhase && restoredPhase !== currentPhase) {
|
||||
if (shouldRestore) {
|
||||
const targetPhase = phases.find(p => p.id === restoredPhase);
|
||||
if (targetPhase && !targetPhase.disabled) {
|
||||
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { debug } from '../../../utils/debug';
|
||||
import { hashContent, getSeoCacheKey } from '../../../utils/contentHash';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
import { getSectionDiffs, DiffPreviewData } from '../../../utils/getSectionDiffs';
|
||||
|
||||
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
|
||||
if (key === undefined || key === null) {
|
||||
@@ -179,6 +180,7 @@ const resolveContentForOutlineSection = (
|
||||
|
||||
interface UseSEOManagerProps {
|
||||
sections: Record<string, string>;
|
||||
introduction?: string;
|
||||
research: any;
|
||||
outline: any[];
|
||||
selectedTitle: string | null;
|
||||
@@ -191,6 +193,7 @@ interface UseSEOManagerProps {
|
||||
setSeoMetadata: (metadata: any) => void;
|
||||
setSections: (sections: Record<string, string>) => void;
|
||||
setSelectedTitle: (title: string | null) => void;
|
||||
setIntroduction: (intro: string) => void;
|
||||
setContinuityRefresh: (timestamp: number) => void;
|
||||
setFlowAnalysisCompleted: (completed: boolean) => void;
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
@@ -198,6 +201,7 @@ interface UseSEOManagerProps {
|
||||
|
||||
export const useSEOManager = ({
|
||||
sections,
|
||||
introduction,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
@@ -210,6 +214,7 @@ export const useSEOManager = ({
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle,
|
||||
setIntroduction,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
@@ -219,6 +224,17 @@ export const useSEOManager = ({
|
||||
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
|
||||
const lastSEOModalOpenRef = useRef<number>(0);
|
||||
|
||||
// Diff preview state
|
||||
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
|
||||
const [diffPreviewData, setDiffPreviewData] = useState<DiffPreviewData | null>(null);
|
||||
const pendingSectionsRef = useRef<Record<string, string> | null>(null);
|
||||
const pendingSectionsKeysRef = useRef<string[] | null>(null);
|
||||
const pendingIntroductionRef = useRef<string | null>(null);
|
||||
const pendingTitleRef = useRef<string | null>(null);
|
||||
const pendingAppliedRef = useRef<any>(null);
|
||||
const originalSectionsRef = useRef<Record<string, string> | null>(null);
|
||||
const originalIntroductionRef = useRef<string | null>(null);
|
||||
|
||||
// Restore cached SEO analysis on mount when sections are available
|
||||
useEffect(() => {
|
||||
const restoreCachedSEO = async () => {
|
||||
@@ -322,6 +338,10 @@ export const useSEOManager = ({
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
// Capture originals before API call for diff preview
|
||||
originalSectionsRef.current = { ...(sections || {}) };
|
||||
originalIntroductionRef.current = introduction || '';
|
||||
|
||||
const existingContentMap = buildExistingContentMap(sections || {});
|
||||
const emptyMap = new Map<string, string>();
|
||||
|
||||
@@ -348,6 +368,7 @@ export const useSEOManager = ({
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
introduction: introduction || undefined,
|
||||
sections: sectionPayload,
|
||||
outline,
|
||||
research: (research as any) || {},
|
||||
@@ -362,6 +383,13 @@ export const useSEOManager = ({
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
if (response.sections.length !== outline.length) {
|
||||
debug.log('[BlogWriter] WARNING: API returned different section count', {
|
||||
apiCount: response.sections.length,
|
||||
outlineCount: outline.length,
|
||||
});
|
||||
}
|
||||
|
||||
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
|
||||
|
||||
const normalizedSections: Record<string, string> = {};
|
||||
@@ -403,48 +431,112 @@ export const useSEOManager = ({
|
||||
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
setSections(normalizedSections);
|
||||
debug.log('[BlogWriter] handleApplySeoRecommendations: computed diffs, showing preview', {
|
||||
keys: Object.keys(normalizedSections),
|
||||
});
|
||||
|
||||
// Store pending changes (don't apply yet)
|
||||
pendingSectionsRef.current = normalizedSections;
|
||||
pendingSectionsKeysRef.current = uniqueSectionKeys;
|
||||
pendingIntroductionRef.current = response.introduction ?? null;
|
||||
pendingTitleRef.current = response.title ?? null;
|
||||
pendingAppliedRef.current = response.applied ?? null;
|
||||
|
||||
// Build diff data from originals vs pending
|
||||
const outlineHeadings = outline.map((s: any) => ({ id: getPrimaryKeyForOutlineSection(s, outline.indexOf(s)), heading: s.heading || s.title || `Section ${outline.indexOf(s) + 1}` }));
|
||||
const diffData = getSectionDiffs(
|
||||
outlineHeadings,
|
||||
originalSectionsRef.current,
|
||||
normalizedSections,
|
||||
originalIntroductionRef.current || undefined,
|
||||
response.introduction || undefined
|
||||
);
|
||||
setDiffPreviewData(diffData);
|
||||
setIsDiffModalOpen(true);
|
||||
|
||||
// Cache the pending content
|
||||
try {
|
||||
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
|
||||
} catch (cacheError) {
|
||||
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
|
||||
}
|
||||
}, [outline, research, sections, introduction, selectedTitle, setSections]);
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
const acceptDiffChanges = useCallback(() => {
|
||||
const normalizedSections = pendingSectionsRef.current;
|
||||
const uniqueSectionKeys = pendingSectionsKeysRef.current;
|
||||
if (!normalizedSections || !uniqueSectionKeys) {
|
||||
debug.log('[BlogWriter] acceptDiffChanges: no pending changes to apply');
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('[BlogWriter] Accepting diff changes, applying sections', {
|
||||
keys: Object.keys(normalizedSections),
|
||||
});
|
||||
|
||||
setSections(normalizedSections);
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
|
||||
if (response.title && response.title !== selectedTitle) {
|
||||
setSelectedTitle(response.title);
|
||||
const pendingIntro = pendingIntroductionRef.current;
|
||||
if (pendingIntro !== null && pendingIntro !== introduction) {
|
||||
setIntroduction(pendingIntro);
|
||||
debug.log('[BlogWriter] Introduction updated from SEO response', {
|
||||
length: pendingIntro.length,
|
||||
preview: pendingIntro.substring(0, 80),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.applied) {
|
||||
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: response.applied } : prev);
|
||||
const pendingTitle = pendingTitleRef.current;
|
||||
if (pendingTitle && pendingTitle !== selectedTitle) {
|
||||
setSelectedTitle(pendingTitle);
|
||||
}
|
||||
|
||||
if (pendingAppliedRef.current) {
|
||||
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: pendingAppliedRef.current } : prev);
|
||||
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
|
||||
}
|
||||
|
||||
// Mark recommendations as applied (this will trigger phase navigation check)
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
try {
|
||||
localStorage.setItem('blog_seo_recommendations_applied', 'true');
|
||||
} catch {}
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after accepting changes');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
|
||||
|
||||
// Clean up pending and close
|
||||
pendingSectionsRef.current = null;
|
||||
pendingSectionsKeysRef.current = null;
|
||||
pendingIntroductionRef.current = null;
|
||||
pendingTitleRef.current = null;
|
||||
pendingAppliedRef.current = null;
|
||||
originalSectionsRef.current = null;
|
||||
originalIntroductionRef.current = null;
|
||||
setIsDiffModalOpen(false);
|
||||
setDiffPreviewData(null);
|
||||
}, [setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setIntroduction, introduction, setSelectedTitle, selectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
|
||||
|
||||
const rejectDiffChanges = useCallback(() => {
|
||||
debug.log('[BlogWriter] Rejecting diff changes, discarding pending content');
|
||||
|
||||
// Clean up pending without applying
|
||||
pendingSectionsRef.current = null;
|
||||
pendingSectionsKeysRef.current = null;
|
||||
pendingIntroductionRef.current = null;
|
||||
pendingTitleRef.current = null;
|
||||
pendingAppliedRef.current = null;
|
||||
originalSectionsRef.current = null;
|
||||
originalIntroductionRef.current = null;
|
||||
setIsDiffModalOpen(false);
|
||||
setDiffPreviewData(null);
|
||||
}, []);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
@@ -518,6 +610,10 @@ export const useSEOManager = ({
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
isDiffModalOpen,
|
||||
diffPreviewData,
|
||||
acceptDiffChanges,
|
||||
rejectDiffChanges,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Typography, Box, Chip, IconButton, Divider
|
||||
} from '@mui/material';
|
||||
import { Close as CloseIcon, Check as CheckIcon } from '@mui/icons-material';
|
||||
import type { DiffPreviewData, DiffSegment } from '../../../utils/getSectionDiffs';
|
||||
|
||||
interface DiffPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
diffData: DiffPreviewData | null;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function renderDiffSegments(segments: DiffSegment[]): React.ReactNode {
|
||||
return segments.map((seg, i) => {
|
||||
if (seg.added) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
key={i}
|
||||
sx={{
|
||||
bgcolor: '#dcfce7',
|
||||
color: '#166534',
|
||||
px: 0.5,
|
||||
borderRadius: '3px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{seg.value}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (seg.removed) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
key={i}
|
||||
sx={{
|
||||
bgcolor: '#fee2e2',
|
||||
color: '#991b1b',
|
||||
px: 0.5,
|
||||
borderRadius: '3px',
|
||||
textDecoration: 'line-through',
|
||||
textDecorationColor: '#dc2626',
|
||||
}}
|
||||
>
|
||||
{seg.value}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{seg.value}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
export const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
|
||||
isOpen,
|
||||
diffData,
|
||||
onAccept,
|
||||
onReject,
|
||||
loading = false,
|
||||
}) => {
|
||||
if (!diffData) return null;
|
||||
|
||||
const hasAnyChange = diffData.introductionChanged || diffData.sectionDiffs.some(s => s.changed);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} maxWidth="lg" fullWidth fullScreen>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
bgcolor: 'white',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
SEO Recommendations — Review Changes
|
||||
</Typography>
|
||||
<IconButton onClick={onReject} size="small"><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<Box sx={{ px: 3, pb: 1.5, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Chip
|
||||
icon={<CheckIcon />}
|
||||
label={`${diffData.sectionDiffs.filter(s => s.changed).length} section(s) changed`}
|
||||
color="warning"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', ml: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', fontSize: '0.75rem', color: '#166534' }}>
|
||||
<Box sx={{ width: 14, height: 14, bgcolor: '#dcfce7', border: '1px solid #86efac', borderRadius: '2px' }} />
|
||||
<span>Added</span>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', fontSize: '0.75rem', color: '#991b1b' }}>
|
||||
<Box sx={{ width: 14, height: 14, bgcolor: '#fee2e2', border: '1px solid #fca5a5', borderRadius: '2px' }} />
|
||||
<span>Removed</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<DialogContent sx={{ py: 3, bgcolor: '#f8fafc' }}>
|
||||
{!hasAnyChange && (
|
||||
<Typography sx={{ textAlign: 'center', py: 4, color: '#64748b' }}>
|
||||
No changes detected between original and recommended content.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{diffData.introductionChanged && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 1 }}>
|
||||
Introduction
|
||||
</Typography>
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
|
||||
{renderDiffSegments(diffData.introductionDiff!)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{diffData.sectionDiffs.map((section, idx) => {
|
||||
if (!section.changed) return null;
|
||||
return (
|
||||
<Box key={section.heading || idx} sx={{ mb: 3 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569', mb: 0.5 }}>
|
||||
{section.heading}
|
||||
</Typography>
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 2, p: 2.5, fontFamily: 'Georgia, serif', fontSize: '1rem', lineHeight: 1.8, color: '#1e293b' }}>
|
||||
{renderDiffSegments(section.segments)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white' }}>
|
||||
<Button
|
||||
onClick={onReject}
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Reject Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
disabled={loading}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Accept Changes
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreviewModal;
|
||||
@@ -910,16 +910,25 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
defaultPrompt={getSectionHeading(imageModalState.sectionId)}
|
||||
context={getSectionContext(imageModalState.sectionId)}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
console.time('[SectionImages] onImageGenerated');
|
||||
if (sectionId && setSectionImages) {
|
||||
setSectionImages((prev: Record<string, string>) => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
try {
|
||||
const existing = JSON.parse(localStorage.getItem('blog_section_images') || '{}');
|
||||
existing[sectionId] = imageBase64;
|
||||
localStorage.setItem('blog_section_images', JSON.stringify(existing));
|
||||
const serialized = JSON.stringify(existing);
|
||||
if (serialized.length > 4_000_000) {
|
||||
console.warn(`[SectionImages] Approaching localStorage quota: ${(serialized.length / 1024 / 1024).toFixed(1)}MB`);
|
||||
}
|
||||
localStorage.setItem('blog_section_images', serialized);
|
||||
console.timeLog('[SectionImages] onImageGenerated', `saved sectionId=${sectionId} base64_len=${imageBase64.length}`);
|
||||
} catch (e) {
|
||||
console.warn('[SectionImages] Failed to persist to localStorage:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[SectionImages] Skipped: sectionId=', sectionId, 'setSectionImages=', !!setSectionImages);
|
||||
}
|
||||
console.timeEnd('[SectionImages] onImageGenerated');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMarkdownProcessor } from '../../../hooks/useMarkdownProcessor';
|
||||
import BlogPreviewModal from '../BlogPreviewModal';
|
||||
import PlayAllTTSButton from '../PlayAllTTSButton';
|
||||
import OnThisPageNav from './OnThisPageNav';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
@@ -34,8 +35,10 @@ interface BlogEditorProps {
|
||||
researchTitles?: string[];
|
||||
aiGeneratedTitles?: string[];
|
||||
sections?: Record<string, string>;
|
||||
introduction?: string;
|
||||
onContentUpdate?: (sections: any[]) => void;
|
||||
onSave?: (content: any) => void;
|
||||
onIntroductionUpdate?: (intro: string) => void;
|
||||
continuityRefresh?: number;
|
||||
flowAnalysisResults?: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
@@ -51,8 +54,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
researchTitles = [],
|
||||
aiGeneratedTitles = [],
|
||||
sections: parentSections,
|
||||
introduction: parentIntroduction,
|
||||
onContentUpdate,
|
||||
onSave,
|
||||
onIntroductionUpdate,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
sectionImages = {},
|
||||
@@ -60,7 +65,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
groundingInsights
|
||||
}) => {
|
||||
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
|
||||
const [introduction, setIntroduction] = useState('');
|
||||
const [introduction, setIntroduction] = useState(parentIntroduction || '');
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
|
||||
@@ -72,6 +77,8 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [currentSectionId, setCurrentSectionId] = useState<string | number | null>(null);
|
||||
const sectionsRef = useRef(sections);
|
||||
useEffect(() => { sectionsRef.current = sections; }, [sections]);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const introInputRef = useRef<HTMLInputElement>(null);
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -132,66 +139,114 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Match the key generation used by getPrimaryKeyForOutlineSection in useSEOManager
|
||||
const getOutlineKey = useCallback((section: any, index: number): string => {
|
||||
const raw = section?.id ?? section?.section_id ?? section?.sectionId ?? section?.sectionID ?? `section_${index + 1}`;
|
||||
return String(raw).trim();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (outline && outline.length > 0) {
|
||||
const initialSections = outline.map((section, index) => ({
|
||||
id: section.id || index + 1,
|
||||
title: section.heading,
|
||||
content: parentSections?.[section.id] || section.key_points?.join(' ') || '',
|
||||
wordCount: section.target_words || 0,
|
||||
sources: section.references?.length || 0,
|
||||
outlineData: {
|
||||
subheadings: section.subheadings || [],
|
||||
keyPoints: section.key_points || [],
|
||||
keywords: section.keywords || [],
|
||||
references: section.references || [],
|
||||
targetWords: section.target_words || 0
|
||||
}
|
||||
}));
|
||||
const initialSections = outline.map((section, index) => {
|
||||
const key = getOutlineKey(section, index);
|
||||
return {
|
||||
id: key,
|
||||
title: section.heading,
|
||||
content: parentSections?.[key] || parentSections?.[section.id] || parentSections?.[index + 1] || parentSections?.[`section_${index + 1}`] || section.key_points?.join(' ') || '',
|
||||
wordCount: section.target_words || 0,
|
||||
sources: section.references?.length || 0,
|
||||
outlineData: {
|
||||
subheadings: section.subheadings || [],
|
||||
keyPoints: section.key_points || [],
|
||||
keywords: section.keywords || [],
|
||||
references: section.references || [],
|
||||
targetWords: section.target_words || 0
|
||||
}
|
||||
};
|
||||
});
|
||||
setSections(initialSections);
|
||||
}
|
||||
}, [outline, parentSections]);
|
||||
}, [outline, parentSections, getOutlineKey]);
|
||||
|
||||
const prevParentSectionsRef = useRef<string>('');
|
||||
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
|
||||
const lastSyncKeyRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentSections || !outline || outline.length === 0) return;
|
||||
|
||||
const parentSectionsString = JSON.stringify(parentSections);
|
||||
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
|
||||
|
||||
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevParentSectionsRef.current = parentSectionsString;
|
||||
prevContinuityRefreshRef.current = continuityRefresh;
|
||||
|
||||
setSections(prevSections => {
|
||||
const updatedSections = prevSections.map(section => {
|
||||
const sectionIdStr = String(section.id);
|
||||
const parentContent = parentSections[section.id] ||
|
||||
parentSections[sectionIdStr] ||
|
||||
parentSections[Number(section.id)];
|
||||
|
||||
if (parentContent !== undefined && parentContent !== section.content) {
|
||||
return { ...section, content: parentContent };
|
||||
// Generate all candidate keys that getIdCandidatesForSection might produce
|
||||
const getSectionCandidates = useCallback((sectionId: string, index: number): string[] => {
|
||||
const candidates: string[] = [sectionId, sectionId.toLowerCase()];
|
||||
candidates.push(`section_${index + 1}`, `Section ${index + 1}`, `section${index + 1}`, `s${index + 1}`, `S${index + 1}`, `${index + 1}`);
|
||||
const asNum = Number(sectionId);
|
||||
if (!isNaN(asNum)) candidates.push(String(asNum));
|
||||
return Array.from(new Set(candidates));
|
||||
}, []);
|
||||
|
||||
// Helper: sync local sections from parentSections, returns true if any content changed
|
||||
const syncFromParentSections = useCallback(() => {
|
||||
if (!parentSections || !outline || outline.length === 0) return false;
|
||||
const currentSections = sectionsRef.current;
|
||||
let didSync = false;
|
||||
const updatedSections = currentSections.map((section, index) => {
|
||||
const candidates = getSectionCandidates(String(section.id), index);
|
||||
let parentContent: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (parentSections[candidate] !== undefined) {
|
||||
parentContent = parentSections[candidate];
|
||||
break;
|
||||
}
|
||||
return section;
|
||||
});
|
||||
|
||||
const hasUpdates = updatedSections.some((section, index) =>
|
||||
section.content !== prevSections[index]?.content
|
||||
);
|
||||
|
||||
if (onContentUpdate && hasUpdates) {
|
||||
}
|
||||
if (parentContent !== undefined && parentContent !== section.content) {
|
||||
didSync = true;
|
||||
return { ...section, content: parentContent };
|
||||
}
|
||||
return section;
|
||||
});
|
||||
if (didSync) {
|
||||
setSections(updatedSections);
|
||||
if (onContentUpdate) {
|
||||
onContentUpdate(updatedSections);
|
||||
}
|
||||
|
||||
return updatedSections;
|
||||
});
|
||||
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
|
||||
}
|
||||
return didSync;
|
||||
}, [parentSections, outline, onContentUpdate, getSectionCandidates]);
|
||||
|
||||
// Effect 1: sync when parentSections content reference changes
|
||||
useEffect(() => {
|
||||
if (!parentSections || !outline || outline.length === 0) return;
|
||||
const parentSectionsString = JSON.stringify(parentSections);
|
||||
if (parentSectionsString === prevParentSectionsRef.current) return;
|
||||
prevParentSectionsRef.current = parentSectionsString;
|
||||
const synced = syncFromParentSections();
|
||||
// Diagnostic: log key alignment between parentSections and outline-derived keys
|
||||
const parentKeys = Object.keys(parentSections);
|
||||
const outlineKeys = outline.map((s: any, i: number) => getOutlineKey(s, i));
|
||||
if (!synced) {
|
||||
debug.log('[BlogEditor] parentSections changed but sync found no updates', {
|
||||
parentKeys,
|
||||
outlineKeys,
|
||||
notInParent: outlineKeys.filter(k => !parentKeys.includes(k)),
|
||||
notInOutline: parentKeys.filter(k => !outlineKeys.includes(k)),
|
||||
});
|
||||
}
|
||||
}, [parentSections, syncFromParentSections]);
|
||||
|
||||
// Sync introduction from parent when it changes
|
||||
useEffect(() => {
|
||||
if (parentIntroduction !== undefined && parentIntroduction !== introduction) {
|
||||
setIntroduction(parentIntroduction);
|
||||
debug.log('[BlogEditor] Introduction synced from parent', {
|
||||
length: parentIntroduction.length,
|
||||
});
|
||||
}
|
||||
}, [parentIntroduction]);
|
||||
|
||||
// Effect 2: explicit forced sync via continuityRefresh (bypasses content-equality guard)
|
||||
useEffect(() => {
|
||||
if (continuityRefresh === undefined || continuityRefresh === 0) return;
|
||||
if (lastSyncKeyRef.current === continuityRefresh) return;
|
||||
lastSyncKeyRef.current = continuityRefresh;
|
||||
const synced = syncFromParentSections();
|
||||
debug.log('[BlogEditor] continuityRefresh sync', { key: continuityRefresh, synced });
|
||||
}, [continuityRefresh, syncFromParentSections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTitle && initialTitle.trim().length > 0) {
|
||||
@@ -272,8 +327,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
|
||||
const handleIntroductionSelect = useCallback((selectedIntroduction: string) => {
|
||||
setIntroduction(selectedIntroduction);
|
||||
onIntroductionUpdate?.(selectedIntroduction);
|
||||
setShowIntroductionModal(false);
|
||||
}, []);
|
||||
}, [onIntroductionUpdate]);
|
||||
|
||||
const toggleSectionExpansion = useCallback((sectionId: any) => {
|
||||
setExpandedSections(prev => {
|
||||
@@ -297,7 +353,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
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="max-w-7xl mx-auto px-[2%] py-6">
|
||||
<div className="flex gap-8">
|
||||
{/* Main editor column */}
|
||||
<div className="flex-1 min-w-0 max-w-4xl" ref={contentContainerRef}>
|
||||
@@ -367,7 +423,10 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
multiline
|
||||
minRows={2}
|
||||
value={introduction}
|
||||
onChange={(e) => setIntroduction(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setIntroduction(e.target.value);
|
||||
onIntroductionUpdate?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => setEditingIntro(false)}
|
||||
placeholder="Write an engaging introduction..."
|
||||
InputProps={{
|
||||
|
||||
@@ -77,6 +77,101 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
|
||||
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) => {
|
||||
@@ -91,7 +186,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
setContent(updatedContent);
|
||||
if (onContentUpdate) onContentUpdate([{ id, content: updatedContent }]);
|
||||
}
|
||||
}
|
||||
},
|
||||
handleFormatText
|
||||
);
|
||||
|
||||
const formatContent = (rawContent: string) => {
|
||||
@@ -352,11 +448,11 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
|
||||
{sectionImage && (
|
||||
<div className="mb-4">
|
||||
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
|
||||
<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="w-full h-auto max-h-96 object-contain"
|
||||
className="block w-full max-w-full h-auto max-h-96 object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,6 +478,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
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' },
|
||||
@@ -402,10 +501,39 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
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) }}
|
||||
/>
|
||||
@@ -417,7 +545,7 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
multiline
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Start writing..."
|
||||
placeholder="Start writing... Use the toolbar above to format text, or type markdown directly."
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
onFocus={handleFocus}
|
||||
@@ -426,14 +554,19 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
inputRef={contentRef}
|
||||
minRows={5}
|
||||
InputProps={{
|
||||
className: `font-serif text-base leading-relaxed text-gray-700 p-0 ${isFocused ? 'bg-white' : 'bg-transparent'}`,
|
||||
style: { lineHeight: '1.8' }
|
||||
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: 'none' },
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: '1px solid #e2e8f0',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
},
|
||||
'& .MuiOutlinedInput-root': { padding: 0 },
|
||||
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
|
||||
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
|
||||
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#4f46e5', borderWidth: 2 },
|
||||
'& .MuiInputBase-input': { padding: '12px 16px !important' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import { chartApi, ChartGenerateResponse } from '../../../services/chartApi';
|
||||
import TextSelectionMenu from './TextSelectionMenu';
|
||||
import CompactSelectionMenu from './CompactSelectionMenu';
|
||||
import ChartGeneratorModal from '../../Chart/ChartGeneratorModal';
|
||||
import LinkSearchModal from '../../Link/LinkSearchModal';
|
||||
import useSmartTypingAssist from './SmartTypingAssist';
|
||||
// import { debug } from '../../../utils/debug'; // Unused import
|
||||
|
||||
interface BlogTextSelectionHandlerProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
|
||||
onFormatText?: (type: string, start?: number, end?: number) => void;
|
||||
}
|
||||
|
||||
const useBlogTextSelectionHandler = (
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
|
||||
onTextReplace?: (originalText: string, newText: string, editType: string) => void
|
||||
onTextReplace?: (originalText: string, newText: string, editType: string) => void,
|
||||
onFormatText?: (type: string, start?: number, end?: number) => void,
|
||||
) => {
|
||||
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; start: number; end: number } | null>(null);
|
||||
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
|
||||
const [isFactChecking, setIsFactChecking] = useState(false);
|
||||
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||
@@ -27,22 +28,13 @@ const useBlogTextSelectionHandler = (
|
||||
const [linkModalText, setLinkModalText] = useState('');
|
||||
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Use the extracted smart typing assist hook
|
||||
const smartTypingAssist = useSmartTypingAssist(contentRef, onTextReplace);
|
||||
|
||||
// Fact-checking functionality
|
||||
const handleCheckFacts = async (text: string) => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] handleCheckFacts called with text:', text);
|
||||
if (!text.trim()) {
|
||||
console.log('🔍 [BlogTextSelectionHandler] No text to check, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 [BlogTextSelectionHandler] Starting fact check for:', text.trim());
|
||||
if (!text.trim()) return;
|
||||
setIsFactChecking(true);
|
||||
setSelectionMenu(null);
|
||||
|
||||
// Progress tracking
|
||||
const progressSteps = [
|
||||
{ step: "Extracting verifiable claims...", progress: 20 },
|
||||
{ step: "Searching for evidence...", progress: 40 },
|
||||
@@ -52,18 +44,14 @@ const useBlogTextSelectionHandler = (
|
||||
];
|
||||
|
||||
let currentStepIndex = 0;
|
||||
|
||||
// Start progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
if (currentStepIndex < progressSteps.length) {
|
||||
setFactCheckProgress(progressSteps[currentStepIndex]);
|
||||
currentStepIndex++;
|
||||
}
|
||||
}, 2000); // Update every 2 seconds
|
||||
}, 2000);
|
||||
|
||||
// Set a timeout for the fact check (120 seconds)
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
|
||||
clearInterval(progressInterval);
|
||||
setFactCheckProgress(null);
|
||||
setIsFactChecking(false);
|
||||
@@ -78,20 +66,16 @@ const useBlogTextSelectionHandler = (
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Fact check timed out after 120 seconds. Please try again with shorter text.'
|
||||
});
|
||||
}, 120000); // 120 second timeout
|
||||
}, 120000);
|
||||
|
||||
try {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
|
||||
const results = await hallucinationDetectorService.detectHallucinations({
|
||||
text: text.trim(),
|
||||
include_sources: true,
|
||||
max_claims: 10
|
||||
});
|
||||
|
||||
console.log('🔍 [BlogTextSelectionHandler] Fact check results received:', results);
|
||||
setFactCheckResults(results);
|
||||
} catch (error) {
|
||||
console.error('🔍 [BlogTextSelectionHandler] Error checking facts:', error);
|
||||
setFactCheckResults({
|
||||
success: false,
|
||||
claims: [],
|
||||
@@ -104,7 +88,6 @@ const useBlogTextSelectionHandler = (
|
||||
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
} finally {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Fact check completed, setting isFactChecking to false');
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
setFactCheckProgress(null);
|
||||
@@ -116,7 +99,6 @@ const useBlogTextSelectionHandler = (
|
||||
setFactCheckResults(null);
|
||||
};
|
||||
|
||||
// Chart generation handler
|
||||
const handleGenerateChart = (text: string) => {
|
||||
setChartModalText(text);
|
||||
setChartModalOpen(true);
|
||||
@@ -148,28 +130,22 @@ const useBlogTextSelectionHandler = (
|
||||
setLinkModalOpen(false);
|
||||
};
|
||||
|
||||
// Blog-specific quick edit functionality for selected text
|
||||
const handleQuickEdit = (editType: string, selectedText: string) => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
|
||||
|
||||
let editedText = selectedText;
|
||||
|
||||
switch (editType) {
|
||||
case 'improve':
|
||||
// Enhance readability and engagement
|
||||
editedText = selectedText.replace(/\./g, '. ').replace(/\s+/g, ' ').trim();
|
||||
if (!editedText.endsWith('.') && !editedText.endsWith('!') && !editedText.endsWith('?')) {
|
||||
editedText += '.';
|
||||
}
|
||||
break;
|
||||
case 'add-transition':
|
||||
// Add transitional phrases
|
||||
const transitions = ['Furthermore,', 'Additionally,', 'Moreover,', 'In essence,', 'As a result,'];
|
||||
const randomTransition = transitions[Math.floor(Math.random() * transitions.length)];
|
||||
editedText = `${randomTransition} ${selectedText.toLowerCase()}`;
|
||||
break;
|
||||
case 'shorten':
|
||||
// Condense while maintaining meaning
|
||||
editedText = selectedText
|
||||
.replace(/\b(very|really|extremely|quite|rather|fairly)\s+/gi, '')
|
||||
.replace(/\b(that|which) (is|are|was|were)\s+/gi, '')
|
||||
@@ -178,11 +154,9 @@ const useBlogTextSelectionHandler = (
|
||||
.trim();
|
||||
break;
|
||||
case 'expand':
|
||||
// Add explanatory content
|
||||
editedText = selectedText + ' This approach provides significant value by offering concrete benefits and actionable insights that readers can immediately implement.';
|
||||
break;
|
||||
case 'professionalize':
|
||||
// Make more formal and professional
|
||||
editedText = selectedText
|
||||
.replace(/\bcan't\b/gi, 'cannot')
|
||||
.replace(/\bwon't\b/gi, 'will not')
|
||||
@@ -193,19 +167,16 @@ const useBlogTextSelectionHandler = (
|
||||
.replace(/\bI believe\b/gi, 'Research indicates that');
|
||||
break;
|
||||
case 'add-data':
|
||||
// Add statistical backing
|
||||
editedText = selectedText + ' According to recent industry studies, this approach has shown measurable improvements in key performance metrics.';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the callback with the edited text
|
||||
if (onTextReplace) {
|
||||
onTextReplace(selectedText, editedText, editType);
|
||||
}
|
||||
|
||||
// Also dispatch custom event for broader compatibility
|
||||
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
|
||||
detail: {
|
||||
originalText: selectedText,
|
||||
@@ -214,12 +185,9 @@ const useBlogTextSelectionHandler = (
|
||||
}
|
||||
}));
|
||||
|
||||
// Close the selection menu
|
||||
setSelectionMenu(null);
|
||||
};
|
||||
|
||||
|
||||
// Close selection menu when clicking outside any selection menu
|
||||
useEffect(() => {
|
||||
if (!selectionMenu) return;
|
||||
|
||||
@@ -240,7 +208,6 @@ const useBlogTextSelectionHandler = (
|
||||
};
|
||||
}, [selectionMenu]);
|
||||
|
||||
// Cleanup progress and timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFactCheckProgress(null);
|
||||
@@ -250,7 +217,6 @@ const useBlogTextSelectionHandler = (
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Text selection handler with debouncing
|
||||
const handleTextSelection = () => {
|
||||
if (selectionTimeoutRef.current) {
|
||||
clearTimeout(selectionTimeoutRef.current);
|
||||
@@ -260,26 +226,27 @@ const useBlogTextSelectionHandler = (
|
||||
try {
|
||||
let text = '';
|
||||
let rect: DOMRect | null = null;
|
||||
let startPos = 0;
|
||||
let endPos = 0;
|
||||
|
||||
const el = contentRef.current;
|
||||
if (el instanceof HTMLTextAreaElement) {
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
startPos = start;
|
||||
endPos = end;
|
||||
if (start !== end) {
|
||||
text = el.value.substring(start, end).trim();
|
||||
try {
|
||||
const { selectionStart, selectionEnd } = el;
|
||||
if (selectionStart !== null && selectionEnd !== null) {
|
||||
const textRect = el.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
||||
const linesBefore = el.value.substring(0, selectionStart).split('\n').length - 1;
|
||||
rect = new DOMRect(
|
||||
textRect.left + 10,
|
||||
textRect.top + (linesBefore * lineHeight) + 10,
|
||||
100,
|
||||
20
|
||||
);
|
||||
}
|
||||
const textRect = el.getBoundingClientRect();
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
||||
const linesBefore = el.value.substring(0, start).split('\n').length - 1;
|
||||
rect = new DOMRect(
|
||||
textRect.left + 10,
|
||||
textRect.top + (linesBefore * lineHeight) + 10,
|
||||
100,
|
||||
20
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
@@ -302,7 +269,7 @@ const useBlogTextSelectionHandler = (
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const x = Math.max(8, Math.min(elRect.left + (elRect.width / 2), window.innerWidth - 280));
|
||||
const y = Math.max(8, elRect.top + window.scrollY - 60);
|
||||
setSelectionMenu({ x, y, text });
|
||||
setSelectionMenu({ x, y, text, start: startPos, end: endPos });
|
||||
return;
|
||||
}
|
||||
setSelectionMenu(null);
|
||||
@@ -312,7 +279,7 @@ const useBlogTextSelectionHandler = (
|
||||
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280));
|
||||
const y = Math.max(8, rect.top + window.scrollY - 60);
|
||||
|
||||
setSelectionMenu({ x, y, text });
|
||||
setSelectionMenu({ x, y, text, start: startPos, end: endPos });
|
||||
} catch (error) {
|
||||
console.error('Text selection error:', error);
|
||||
setSelectionMenu(null);
|
||||
@@ -330,51 +297,57 @@ const useBlogTextSelectionHandler = (
|
||||
handleCheckFacts,
|
||||
handleCloseFactCheckResults,
|
||||
handleQuickEdit,
|
||||
// Smart typing assist functionality from extracted hook
|
||||
...smartTypingAssist,
|
||||
// Render the selection menu and fact-check components
|
||||
renderSelectionMenu: () => (
|
||||
<>
|
||||
<TextSelectionMenu
|
||||
selectionMenu={selectionMenu}
|
||||
factCheckResults={factCheckResults}
|
||||
isFactChecking={isFactChecking}
|
||||
factCheckProgress={factCheckProgress}
|
||||
smartSuggestion={smartTypingAssist.smartSuggestion}
|
||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||
allSuggestions={smartTypingAssist.allSuggestions}
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onGenerateChart={handleGenerateChart}
|
||||
onFindLinks={handleFindLinks}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||
/>
|
||||
{chartModalOpen && (
|
||||
<ChartGeneratorModal
|
||||
isOpen={chartModalOpen}
|
||||
onClose={() => setChartModalOpen(false)}
|
||||
defaultText={chartModalText}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
<CompactSelectionMenu
|
||||
selectionMenu={selectionMenu}
|
||||
factCheckResults={factCheckResults}
|
||||
isFactChecking={isFactChecking}
|
||||
factCheckProgress={factCheckProgress}
|
||||
smartSuggestion={smartTypingAssist.smartSuggestion}
|
||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||
allSuggestions={smartTypingAssist.allSuggestions}
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onGenerateChart={handleGenerateChart}
|
||||
onFindLinks={handleFindLinks}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||
onFormatText={(type: string) => {
|
||||
if (selectionMenu) {
|
||||
onFormatText?.(type, selectionMenu.start, selectionMenu.end);
|
||||
} else {
|
||||
onFormatText?.(type);
|
||||
}
|
||||
setSelectionMenu(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{linkModalOpen && (
|
||||
<LinkSearchModal
|
||||
isOpen={linkModalOpen}
|
||||
onClose={() => setLinkModalOpen(false)}
|
||||
sectionHeading=""
|
||||
sectionText={linkModalText}
|
||||
selectedText={linkModalText}
|
||||
onRewordAccept={handleLinkRewordAccept}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip, IconButton, Divider } from '@mui/material';
|
||||
import {
|
||||
FormatBold as BoldIcon,
|
||||
FormatItalic as ItalicIcon,
|
||||
Link as LinkIcon,
|
||||
FormatListBulleted as BulletListIcon,
|
||||
FormatListNumbered as NumberedListIcon,
|
||||
FormatQuote as QuoteIcon,
|
||||
Code as CodeIcon,
|
||||
HorizontalRule as HrIcon,
|
||||
Title as TitleIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CompactSelectionMenuProps {
|
||||
selectionMenu: { x: number; y: number; text: string; start: number; end: number } | null;
|
||||
factCheckResults: any;
|
||||
isFactChecking: boolean;
|
||||
factCheckProgress: { step: string; progress: number } | null;
|
||||
smartSuggestion: any;
|
||||
isGeneratingSuggestion: boolean;
|
||||
allSuggestions: any[];
|
||||
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;
|
||||
onRejectSuggestion: () => void;
|
||||
onNextSuggestion: () => void;
|
||||
onRequestSuggestion: () => void;
|
||||
onDismissPrompt: () => void;
|
||||
onFormatText: (type: string, start?: number, end?: number) => void;
|
||||
}
|
||||
|
||||
const formatBtnSx = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)', color: 'white' },
|
||||
};
|
||||
|
||||
const formatButtons = [
|
||||
{ type: 'bold', icon: <BoldIcon sx={{ fontSize: 16 }} />, label: 'Bold' },
|
||||
{ type: 'italic', icon: <ItalicIcon sx={{ fontSize: 16 }} />, label: 'Italic' },
|
||||
{ type: 'link', icon: <LinkIcon sx={{ fontSize: 16 }} />, label: 'Link' },
|
||||
{ type: 'heading-2', icon: <TitleIcon sx={{ fontSize: 15, transform: 'scaleX(1.2)' }} />, label: 'H2' },
|
||||
{ type: 'heading-3', icon: <TitleIcon sx={{ fontSize: 13, transform: 'scaleX(1.1)' }} />, label: 'H3' },
|
||||
{ type: 'bullet-list', icon: <BulletListIcon sx={{ fontSize: 16 }} />, label: 'Bullet' },
|
||||
{ type: 'numbered-list', icon: <NumberedListIcon sx={{ fontSize: 16 }} />, label: 'Numbered' },
|
||||
{ type: 'blockquote', icon: <QuoteIcon sx={{ fontSize: 16 }} />, label: 'Quote' },
|
||||
{ type: 'code', icon: <CodeIcon sx={{ fontSize: 16 }} />, label: 'Code' },
|
||||
{ type: 'hr', icon: <HrIcon sx={{ fontSize: 16 }} />, label: 'HR' },
|
||||
];
|
||||
|
||||
const aiButtons = [
|
||||
{ type: 'improve', label: '✏️ Improve Shorten Expand' },
|
||||
];
|
||||
|
||||
const btnBase: React.CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '5px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
};
|
||||
|
||||
const CompactSelectionMenu: React.FC<CompactSelectionMenuProps> = ({
|
||||
selectionMenu,
|
||||
factCheckResults,
|
||||
isFactChecking,
|
||||
factCheckProgress,
|
||||
smartSuggestion,
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
showContinueWritingPrompt,
|
||||
onCheckFacts,
|
||||
onGenerateChart,
|
||||
onFindLinks,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
onNextSuggestion,
|
||||
onRequestSuggestion,
|
||||
onDismissPrompt,
|
||||
onFormatText,
|
||||
}) => {
|
||||
if (!selectionMenu) return null;
|
||||
|
||||
const x = Math.max(8, Math.min(selectionMenu.x - 180, window.innerWidth - 380));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-selection-menu="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: selectionMenu.y - 10,
|
||||
left: x,
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
padding: '8px 10px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10000,
|
||||
minWidth: '340px',
|
||||
maxWidth: '380px',
|
||||
}}
|
||||
>
|
||||
{/* Formatting Section */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.8px',
|
||||
marginBottom: '4px',
|
||||
paddingLeft: '2px',
|
||||
}}>
|
||||
✏️ Format
|
||||
</div>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{formatButtons.map(btn => (
|
||||
<Tooltip key={btn.type} title={btn.label} arrow>
|
||||
<IconButton size="small" sx={formatBtnSx} onClick={() => onFormatText(btn.type)}>
|
||||
{btn.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.15)', my: '4px' }} />
|
||||
|
||||
{/* AI Tools Section */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.8px',
|
||||
marginBottom: '4px',
|
||||
paddingLeft: '2px',
|
||||
}}>
|
||||
🤖 AI Tools
|
||||
</div>
|
||||
|
||||
{/* Primary AI Actions */}
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCheckFacts(selectionMenu.text); }}
|
||||
disabled={isFactChecking}
|
||||
style={{
|
||||
...btnBase,
|
||||
background: isFactChecking ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.15)',
|
||||
opacity: isFactChecking ? 0.6 : 1,
|
||||
cursor: isFactChecking ? 'not-allowed' : 'pointer',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isFactChecking) e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
|
||||
onMouseLeave={(e) => { if (!isFactChecking) e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
|
||||
>
|
||||
{isFactChecking ? '⏳ Verifying...' : '🔍 Fact Check'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGenerateChart(selectionMenu.text); }}
|
||||
style={{ ...btnBase, flex: 1, justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(124,58,237,0.3)', border: '1px solid rgba(124,58,237,0.4)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124,58,237,0.45)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124,58,237,0.3)'; }}
|
||||
>
|
||||
📊 Chart
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onFindLinks(selectionMenu.text); }}
|
||||
style={{ ...btnBase, flex: 1, justifyContent: 'center', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(16,185,129,0.3)', border: '1px solid rgba(16,185,129,0.4)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(16,185,129,0.45)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(16,185,129,0.3)'; }}
|
||||
>
|
||||
🔗 Links
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Edit Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{['improve', 'shorten', 'expand', 'professionalize', 'add-transition', 'add-data'].map(type => {
|
||||
const labels: Record<string, string> = {
|
||||
improve: '✏️ Improve',
|
||||
shorten: '✂️ Shorten',
|
||||
expand: '📝 Expand',
|
||||
professionalize: '🎓 Professional',
|
||||
'add-transition': '🔗 Transition',
|
||||
'add-data': '📊 Add Data',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={(e) => { e.stopPropagation(); onQuickEdit(type, selectionMenu.text); }}
|
||||
style={btnBase}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fact Check Progress */}
|
||||
{factCheckProgress && (
|
||||
<div style={{
|
||||
marginTop: '4px',
|
||||
padding: '6px 8px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<span style={{ fontSize: '11px', color: 'rgba(255,255,255,0.85)' }}>{factCheckProgress.step}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactSelectionMenu;
|
||||
117
frontend/src/components/BlogWriter/WYSIWYG/MarkdownToolbar.tsx
Normal file
117
frontend/src/components/BlogWriter/WYSIWYG/MarkdownToolbar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { Box, Tooltip, IconButton, Divider } from '@mui/material';
|
||||
import {
|
||||
FormatBold as BoldIcon,
|
||||
FormatItalic as ItalicIcon,
|
||||
Link as LinkIcon,
|
||||
FormatListBulleted as BulletListIcon,
|
||||
FormatListNumbered as NumberedListIcon,
|
||||
FormatQuote as QuoteIcon,
|
||||
Code as CodeIcon,
|
||||
HorizontalRule as HrIcon,
|
||||
Title as TitleIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
onFormat: (type: string) => void;
|
||||
}
|
||||
|
||||
interface ToolbarButton {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const buttons: ToolbarButton[] = [
|
||||
{ type: 'bold', icon: <BoldIcon sx={{ fontSize: 18 }} />, tooltip: 'Bold', shortcut: 'Ctrl+B' },
|
||||
{ type: 'italic', icon: <ItalicIcon sx={{ fontSize: 18 }} />, tooltip: 'Italic', shortcut: 'Ctrl+I' },
|
||||
{ type: 'link', icon: <LinkIcon sx={{ fontSize: 18 }} />, tooltip: 'Insert Link' },
|
||||
];
|
||||
|
||||
const headingButtons: ToolbarButton[] = [
|
||||
{ type: 'heading-2', icon: <TitleIcon sx={{ fontSize: 18, transform: 'scaleX(1.3)' }} />, tooltip: 'Subheading (H2)' },
|
||||
{ type: 'heading-3', icon: <TitleIcon sx={{ fontSize: 15, transform: 'scaleX(1.2)' }} />, tooltip: 'Subheading (H3)' },
|
||||
];
|
||||
|
||||
const listButtons: ToolbarButton[] = [
|
||||
{ type: 'bullet-list', icon: <BulletListIcon sx={{ fontSize: 18 }} />, tooltip: 'Bullet List' },
|
||||
{ type: 'numbered-list', icon: <NumberedListIcon sx={{ fontSize: 18 }} />, tooltip: 'Numbered List' },
|
||||
];
|
||||
|
||||
const blockButtons: ToolbarButton[] = [
|
||||
{ type: 'blockquote', icon: <QuoteIcon sx={{ fontSize: 18 }} />, tooltip: 'Blockquote' },
|
||||
{ type: 'code', icon: <CodeIcon sx={{ fontSize: 18 }} />, tooltip: 'Inline Code' },
|
||||
{ type: 'hr', icon: <HrIcon sx={{ fontSize: 18 }} />, tooltip: 'Horizontal Rule' },
|
||||
];
|
||||
|
||||
const btnSx = {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '6px',
|
||||
color: '#64748b',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: '#eef2ff',
|
||||
color: '#4f46e5',
|
||||
},
|
||||
};
|
||||
|
||||
const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({ onFormat }) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
bgcolor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderBottom: 'none',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{buttons.map(btn => (
|
||||
<Tooltip key={btn.type} title={btn.shortcut ? `${btn.tooltip} (${btn.shortcut})` : btn.tooltip} arrow>
|
||||
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
|
||||
{btn.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
|
||||
|
||||
{headingButtons.map(btn => (
|
||||
<Tooltip key={btn.type} title={btn.tooltip} arrow>
|
||||
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
|
||||
{btn.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
|
||||
|
||||
{listButtons.map(btn => (
|
||||
<Tooltip key={btn.type} title={btn.tooltip} arrow>
|
||||
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
|
||||
{btn.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: '#e2e8f0' }} />
|
||||
|
||||
{blockButtons.map(btn => (
|
||||
<Tooltip key={btn.type} title={btn.tooltip} arrow>
|
||||
<IconButton size="small" sx={btnSx} onClick={() => onFormat(btn.type)}>
|
||||
{btn.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownToolbar;
|
||||
@@ -1,741 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
selectionMenu: { x: number; y: number; text: string } | null;
|
||||
factCheckResults: HallucinationDetectionResponse | null;
|
||||
isFactChecking: boolean;
|
||||
factCheckProgress: { step: string; progress: number } | null;
|
||||
smartSuggestion: {
|
||||
text: string;
|
||||
position: { x: number; y: number };
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
} | null;
|
||||
isGeneratingSuggestion: boolean;
|
||||
allSuggestions: Array<{
|
||||
text: string;
|
||||
confidence?: number;
|
||||
sources?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
text?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
score: number;
|
||||
}>;
|
||||
}>;
|
||||
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;
|
||||
onRejectSuggestion: () => void;
|
||||
onNextSuggestion: () => void;
|
||||
onRequestSuggestion: () => void;
|
||||
onDismissPrompt: () => void;
|
||||
}
|
||||
|
||||
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
selectionMenu,
|
||||
factCheckResults,
|
||||
isFactChecking,
|
||||
factCheckProgress,
|
||||
smartSuggestion,
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
showContinueWritingPrompt,
|
||||
onCheckFacts,
|
||||
onGenerateChart,
|
||||
onFindLinks,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
onNextSuggestion,
|
||||
onRequestSuggestion,
|
||||
onDismissPrompt
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Text Selection Menu */}
|
||||
{selectionMenu && (
|
||||
<div
|
||||
data-selection-menu="true"
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: selectionMenu.y - 60,
|
||||
left: Math.max(8, selectionMenu.x - 140),
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10000,
|
||||
minWidth: '240px',
|
||||
maxWidth: '280px'
|
||||
}}
|
||||
>
|
||||
{/* Fact Check Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Check Facts button clicked!', selectionMenu.text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCheckFacts(selectionMenu.text);
|
||||
}}
|
||||
disabled={isFactChecking}
|
||||
style={{
|
||||
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isFactChecking ? 'not-allowed' : 'pointer',
|
||||
opacity: isFactChecking ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isFactChecking) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFactChecking ? (
|
||||
<>
|
||||
<div style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Fact-checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔍 Fact Check
|
||||
</>
|
||||
)}
|
||||
</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)',
|
||||
paddingTop: '10px',
|
||||
marginTop: '6px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
✨ Assistive Writing
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('improve', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✏️ Improve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('add-transition', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🔗 Transition
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('shorten', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✂️ Shorten
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('expand', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📝 Expand
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('professionalize', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
🎓 Professional
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onQuickEdit('add-data', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 10px',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
📊 Add Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Progress */}
|
||||
{factCheckProgress && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(79, 70, 229, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '280px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Fact-checking content...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
{factCheckProgress.step}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Results */}
|
||||
{factCheckResults && (
|
||||
<FactCheckResults
|
||||
results={factCheckResults}
|
||||
onClose={onCloseFactCheckResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Smart Typing Suggestion */}
|
||||
{smartSuggestion && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Smart suggestion modal clicked!', smartSuggestion);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: smartSuggestion.position.y,
|
||||
left: smartSuggestion.position.x,
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10002,
|
||||
maxWidth: '420px',
|
||||
minWidth: '320px',
|
||||
maxHeight: '350px',
|
||||
overflow: 'auto',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
opacity: 0.9,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>✨ Smart Writing Suggestion</span>
|
||||
{allSuggestions.length > 1 && (
|
||||
<span style={{ fontSize: '10px', opacity: 0.7 }}>
|
||||
{suggestionIndex + 1} of {allSuggestions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '16px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
"{smartSuggestion.text}"
|
||||
</div>
|
||||
|
||||
{smartSuggestion.sources && smartSuggestion.sources.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.2)',
|
||||
paddingTop: '10px'
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.8, marginBottom: '6px' }}>
|
||||
Sources:
|
||||
</div>
|
||||
{smartSuggestion.sources.slice(0, 2).map((src, i) => (
|
||||
<div key={i} style={{ fontSize: '11px', opacity: 0.85, marginBottom: '4px', lineHeight: '1.3' }}>
|
||||
<a href={src.url} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'white', textDecoration: 'underline' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{src.title || src.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{allSuggestions.length > 1 && suggestionIndex < allSuggestions.length - 1 && (
|
||||
<button
|
||||
onClick={onNextSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
}}
|
||||
>
|
||||
↻ Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onRejectSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
✕ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onAcceptSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✓ Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestion Loading Indicator */}
|
||||
{isGeneratingSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(34, 197, 94, 0.95)',
|
||||
color: 'white',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
minWidth: '240px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
Generating suggestion...
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.8 }}>
|
||||
AI is crafting helpful content
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Writing Prompt */}
|
||||
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(59, 130, 246, 0.95)',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
minWidth: '280px',
|
||||
maxWidth: '360px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
✨ AI Writing Assistant
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onRequestSuggestion}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✍️ Continue Writing
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissPrompt}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextSelectionMenu;
|
||||
@@ -214,6 +214,8 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
|
||||
}, [negative]);
|
||||
|
||||
const suggestPrompt = async () => {
|
||||
console.time('[suggestPrompt] total');
|
||||
console.time('[suggestPrompt] pre-call');
|
||||
setLoadingSuggestions(true);
|
||||
setSuggestionError(null);
|
||||
try {
|
||||
@@ -225,7 +227,10 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
|
||||
research: context?.research || undefined,
|
||||
persona: context?.persona || undefined,
|
||||
};
|
||||
console.timeLog('[suggestPrompt] pre-call', 'calling fetchPromptSuggestions');
|
||||
console.time('[suggestPrompt] fetchPromptSuggestions');
|
||||
const suggs = await fetchPromptSuggestions(payload);
|
||||
console.timeLog('[suggestPrompt] fetchPromptSuggestions', 'response received');
|
||||
setSuggestions(suggs);
|
||||
if (suggs.length > 0) {
|
||||
setPrompt(suggs[0].prompt || '');
|
||||
@@ -238,10 +243,13 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
|
||||
setSuggestionError(e instanceof Error ? e.message : 'Failed to optimize prompt. The API is unavailable.');
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
console.timeLog('[suggestPrompt] total', 'done');
|
||||
console.timeEnd('[suggestPrompt] total');
|
||||
}
|
||||
};
|
||||
|
||||
const onGenerate = async () => {
|
||||
console.time('[onGenerate] total');
|
||||
if (width > MAX_DIMENSIONS.maxWidth || height > MAX_DIMENSIONS.maxHeight) {
|
||||
alert(`Resolution ${width}x${height} exceeds maximum ${MAX_DIMENSIONS.maxWidth}x${MAX_DIMENSIONS.maxHeight} for model ${model}. Please adjust the dimensions.`);
|
||||
return;
|
||||
@@ -256,12 +264,15 @@ export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGenera
|
||||
height,
|
||||
overlay_text: suggestion?.overlay_text || undefined,
|
||||
};
|
||||
console.time('[onGenerate] generate');
|
||||
const res = await generate(req);
|
||||
console.timeLog('[onGenerate] generate', 'done');
|
||||
if (res && onImageReady) onImageReady(res.image_base64);
|
||||
try {
|
||||
const { publishImage } = await import('../../utils/imageBus');
|
||||
publishImage({ base64: res.image_base64, provider: res.provider, model: res.model });
|
||||
} catch {}
|
||||
console.timeEnd('[onGenerate] total');
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { apiClient, aiApiClient } from '../../api/client';
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
prompt: string;
|
||||
@@ -87,9 +87,9 @@ export async function fetchPromptSuggestions(payload: {
|
||||
research?: any;
|
||||
persona?: any;
|
||||
}): Promise<PromptSuggestion[]> {
|
||||
// Use apiClient directly (same pattern as SEO analysis in SEOAnalysisModal.tsx)
|
||||
// The apiClient interceptor will handle auth token injection automatically
|
||||
const response = await apiClient.post('/api/images/suggest-prompts', payload);
|
||||
// Use aiApiClient (3-minute timeout) because suggest-prompts calls an LLM
|
||||
// which can take 30-60+ seconds to respond via WaveSpeed
|
||||
const response = await aiApiClient.post('/api/images/suggest-prompts', payload);
|
||||
return response.data.suggestions || [];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent
|
||||
CardContent,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -24,6 +25,11 @@ import {
|
||||
SkipNext as SkipIcon,
|
||||
NavigateNext,
|
||||
Psychology as AgentIcon,
|
||||
TrendingUp as TrendUpIcon,
|
||||
TrendingDown as TrendDownIcon,
|
||||
TrendingFlat as TrendFlatIcon,
|
||||
GpsFixed as GapIcon,
|
||||
BarChart as VolumeIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWorkflowStore } from '../../../stores/workflowStore';
|
||||
@@ -155,7 +161,78 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
|
||||
const isLastPillar = currentPillarIndex === pillarOrder.length - 1;
|
||||
const nextPillarId = !isLastPillar ? pillarOrder[currentPillarIndex + 1] : null;
|
||||
|
||||
const getTaskStatus = (task: TodayTask) => {
|
||||
const MetricBar = ({ label, value, color }: { label: string; value: number; color: string }) => (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: '#888', fontWeight: 600 }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 700 }}>{(value * 100).toFixed(0)}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={value * 100}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: '#e8e8e8',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const GapScoringBreakdown = ({ scoring }: { scoring: Record<string, number> }) => {
|
||||
const roi = scoring.roi_score ?? scoring.roi ?? 0;
|
||||
const roiColor = roi >= 0.6 ? '#2e7d32' : roi >= 0.3 ? '#f57c00' : '#9e9e9e';
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, p: 1.5, bgcolor: '#f5f7fa', borderRadius: 2, border: '1px solid #e0e4e8' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<GapIcon sx={{ fontSize: 18, color: roiColor }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#333', flexGrow: 1 }}>
|
||||
Opportunity Score
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${(roi * 100).toFixed(0)}% ROI`}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
bgcolor: `${roiColor}18`,
|
||||
color: roiColor,
|
||||
border: `1px solid ${roiColor}40`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<MetricBar label="Gap Size" value={scoring.gap_size ?? 0} color="#1565C0" />
|
||||
<MetricBar label="Search Volume" value={scoring.volume ?? 0} color="#7b1fa2" />
|
||||
<MetricBar label="Competition" value={scoring.competition ?? 0} color="#c62828" />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
{(() => {
|
||||
const t = scoring.trend ?? 0.5;
|
||||
const Icon = t > 0.6 ? TrendUpIcon : t < 0.4 ? TrendDownIcon : TrendFlatIcon;
|
||||
const tColor = t > 0.6 ? '#2e7d32' : t < 0.4 ? '#c62828' : '#f57c00';
|
||||
return <Icon sx={{ fontSize: 16, color: tColor }} />;
|
||||
})()}
|
||||
<Typography variant="caption" sx={{ color: '#888', fontWeight: 600 }}>
|
||||
Trend: {(scoring.trend ?? 0.5) >= 0.6 ? 'Rising' : (scoring.trend ?? 0.5) <= 0.4 ? 'Declining' : 'Stable'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={scoring.intent && scoring.intent >= 0.7 ? 'Commercial' : scoring.intent && scoring.intent >= 0.5 ? 'Transactional' : 'Informational'}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
bgcolor: '#e3f2fd',
|
||||
color: '#1565C0',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const getTaskStatus = (task: TodayTask) => {
|
||||
if (task.status === 'completed') return 'completed';
|
||||
if (task.status === 'in_progress') return 'active';
|
||||
if (task.status === 'skipped') return 'skipped';
|
||||
@@ -367,7 +444,7 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
|
||||
gap: 1.5
|
||||
}}>
|
||||
<AgentIcon sx={{ fontSize: 16, color: pillarColor, mt: 0.3 }} />
|
||||
<Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#444' }}>
|
||||
Suggested by {task.metadata.source_agent.replace('Agent', '')}
|
||||
@@ -378,6 +455,10 @@ const EnhancedTodayModal: React.FC<EnhancedTodayModalProps> = ({
|
||||
"{task.metadata.reasoning}"
|
||||
</Typography>
|
||||
)}
|
||||
{/* Gap scoring breakdown for ContentGapRadarAgent tasks */}
|
||||
{task.metadata.source_agent === 'ContentGapRadarAgent' && task.metadata.context_data?.gap?.scoring && (
|
||||
<GapScoringBreakdown scoring={task.metadata.context_data.gap.scoring} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
// import OnboardingButton from '../common/OnboardingButton';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
|
||||
import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components';
|
||||
import { FinalStepProps, OnboardingData, Capability } from './types';
|
||||
import { SetupSummary, CapabilitiesOverview, AgentTeamSection, TaskSchedulingPanel } from './components';
|
||||
import { FinalStepProps, OnboardingData, Capability, OnboardingCompletionResult } from './types';
|
||||
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam';
|
||||
|
||||
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
@@ -35,6 +35,8 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
|
||||
const [agentTeam, setAgentTeam] = useState<AgentTeamCatalogEntry[]>([]);
|
||||
const [agentTeamError, setAgentTeamError] = useState<string | null>(null);
|
||||
const [completionResult, setCompletionResult] = useState<OnboardingCompletionResult | null>(null);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
// const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,6 +49,25 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
// Auto-redirect countdown after successful onboarding completion
|
||||
useEffect(() => {
|
||||
if (completionResult && countdown === null) {
|
||||
setCountdown(8);
|
||||
}
|
||||
if (countdown === null || countdown <= 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown(prev => {
|
||||
const next = (prev ?? 0) - 1;
|
||||
if (next <= 0) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
return 0;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [completionResult, countdown, navigate]);
|
||||
|
||||
// Remove the DOM manipulation approach - we'll use React's built-in event handling
|
||||
|
||||
const loadOnboardingData = async () => {
|
||||
@@ -300,9 +321,16 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
localStorage.setItem('onboarding_active_step', String(stepsLengthFallback()));
|
||||
} catch {}
|
||||
|
||||
// Navigate directly to dashboard using React Router
|
||||
console.log('FinalStep: Navigating to dashboard with react-router navigate("/dashboard")');
|
||||
navigate('/dashboard', { replace: true });
|
||||
// Show TaskSchedulingPanel with completion result (auto-redirect starts)
|
||||
const typedResult: OnboardingCompletionResult = {
|
||||
message: completionResult?.message || 'Onboarding completed successfully',
|
||||
completed_at: completionResult?.completed_at || new Date().toISOString(),
|
||||
completion_percentage: completionResult?.completion_percentage ?? 100,
|
||||
persona_generated: completionResult?.persona_generated ?? false,
|
||||
scheduled_tasks: completionResult?.scheduled_tasks || [],
|
||||
failed_tasks: completionResult?.failed_tasks || null,
|
||||
};
|
||||
setCompletionResult(typedResult);
|
||||
} catch (e: any) {
|
||||
console.error('FinalStep: Error completing onboarding:', e);
|
||||
console.error('FinalStep: Error details:', {
|
||||
@@ -411,113 +439,157 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
{/* Content - Only show when data is loaded */}
|
||||
{!dataLoading && (
|
||||
<React.Fragment>
|
||||
{/* Setup Summary */}
|
||||
<SetupSummary
|
||||
onboardingData={onboardingData}
|
||||
capabilities={capabilities}
|
||||
expandedSection={expandedSection}
|
||||
setExpandedSection={setExpandedSection}
|
||||
/>
|
||||
{/* Post-completion: show TaskSchedulingPanel and hide setup details */}
|
||||
{completionResult ? (
|
||||
<React.Fragment>
|
||||
<TaskSchedulingPanel
|
||||
scheduledTasks={completionResult.scheduled_tasks}
|
||||
failedTasks={completionResult.failed_tasks || []}
|
||||
personaGenerated={completionResult.persona_generated}
|
||||
completedAt={completionResult.completed_at}
|
||||
/>
|
||||
|
||||
{/* Capabilities Overview */}
|
||||
<CapabilitiesOverview capabilities={capabilities} />
|
||||
|
||||
{/* Agent Team */}
|
||||
{agentTeamError && (
|
||||
<Alert severity="warning" sx={{ mt: 3, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Agent team configuration unavailable
|
||||
</Typography>
|
||||
<Typography variant="body2">{agentTeamError}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!agentTeamError && agentTeam.length > 0 && (
|
||||
<AgentTeamSection websiteName={websiteName} agents={agentTeam} contextCard={agentContextCard} />
|
||||
)}
|
||||
|
||||
{/* Missing Requirements Warning */}
|
||||
{missingRequirements.length > 0 && (
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small">
|
||||
Configure Now
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Missing Requirements
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Zoom>
|
||||
)}
|
||||
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3, gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<Rocket />}
|
||||
onClick={() => navigate('/dashboard', { replace: true })}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
px: 4,
|
||||
py: 2,
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
boxShadow: '0 10px 28px rgba(15,23,42,0.45)',
|
||||
letterSpacing: 0.2,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #020617 0%, #1e1b4b 40%, #4338ca 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 14px 36px rgba(15,23,42,0.55)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Auto-redirecting to dashboard in {countdown ?? 0}s...
|
||||
</Typography>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{/* Setup Summary */}
|
||||
<SetupSummary
|
||||
onboardingData={onboardingData}
|
||||
capabilities={capabilities}
|
||||
expandedSection={expandedSection}
|
||||
setExpandedSection={setExpandedSection}
|
||||
/>
|
||||
|
||||
{/* Capabilities Overview */}
|
||||
<CapabilitiesOverview capabilities={capabilities} />
|
||||
|
||||
{/* Agent Team */}
|
||||
{agentTeamError && (
|
||||
<Alert severity="warning" sx={{ mt: 3, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Agent team configuration unavailable
|
||||
</Typography>
|
||||
<Typography variant="body2">{agentTeamError}</Typography>
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!agentTeamError && agentTeam.length > 0 && (
|
||||
<AgentTeamSection websiteName={websiteName} agents={agentTeam} contextCard={agentContextCard} />
|
||||
)}
|
||||
|
||||
{/* Validation Status */}
|
||||
{validationStatus && !validationStatus.isValid && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
The following steps need to be completed before launching:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{validationStatus.missingSteps.map((step, index) => (
|
||||
<li key={index}>
|
||||
<Typography variant="body2">{step}</Typography>
|
||||
</li>
|
||||
))}
|
||||
{/* Missing Requirements Warning */}
|
||||
{missingRequirements.length > 0 && (
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small">
|
||||
Configure Now
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Missing Requirements
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Zoom>
|
||||
)}
|
||||
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
action={
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Validation Status */}
|
||||
{validationStatus && !validationStatus.isValid && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
The following steps need to be completed before launching:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{validationStatus.missingSteps.map((step, index) => (
|
||||
<li key={index}>
|
||||
<Typography variant="body2">{step}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Launch Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading || dataLoading}
|
||||
onClick={handleLaunch}
|
||||
startIcon={<Rocket />}
|
||||
sx={{
|
||||
{/* Launch Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading || dataLoading}
|
||||
onClick={handleLaunch}
|
||||
startIcon={<Rocket />}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
@@ -539,25 +611,27 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
transform: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Launch Alwrity & Complete Setup
|
||||
</Button>
|
||||
</Box>
|
||||
>
|
||||
Launch Alwrity & Complete Setup
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Help Text */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
This will complete your onboarding and launch Alwrity with your configured settings.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
|
||||
>
|
||||
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
|
||||
Your SIF Agent Framework is ready to orchestrate your marketing.
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Help Text */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
This will complete your onboarding and launch Alwrity with your configured settings.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
|
||||
>
|
||||
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
|
||||
Your SIF Agent Framework is ready to orchestrate your marketing.
|
||||
</Typography>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Zoom,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
ErrorOutline,
|
||||
ExpandMore,
|
||||
ExpandLess,
|
||||
RocketLaunch,
|
||||
Autorenew,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export interface ScheduledTask {
|
||||
task: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TaskSchedulingPanelProps {
|
||||
scheduledTasks: string[];
|
||||
failedTasks: ScheduledTask[];
|
||||
personaGenerated: boolean;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
const TASK_LABELS: Record<string, string> = {
|
||||
research_persona: 'Research Persona Generation',
|
||||
facebook_persona: 'Facebook Persona Generation',
|
||||
oauth_monitoring: 'OAuth Token Monitoring',
|
||||
website_analysis: 'Website Analysis',
|
||||
full_site_seo_audit: 'Full-Site SEO Audit',
|
||||
sif_indexing: 'SIF Indexing',
|
||||
market_trends: 'Market Trends',
|
||||
deep_competitor_analysis: 'Deep Competitor Analysis',
|
||||
market_trends_no_url: 'Market Trends (no website)',
|
||||
progressive_setup: 'User Environment Setup',
|
||||
};
|
||||
|
||||
function getTaskLabel(key: string): string {
|
||||
return TASK_LABELS[key] || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function getTaskType(key: string): 'oneshot' | 'recurring' | 'setup' | 'unknown' {
|
||||
if (['research_persona', 'facebook_persona', 'website_analysis'].includes(key)) return 'oneshot';
|
||||
if (['full_site_seo_audit', 'sif_indexing', 'market_trends', 'market_trends_no_url', 'deep_competitor_analysis', 'oauth_monitoring'].includes(key)) return 'recurring';
|
||||
if (key === 'progressive_setup') return 'setup';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getTaskTypeChip(type: 'oneshot' | 'recurring' | 'setup' | 'unknown') {
|
||||
const config = {
|
||||
oneshot: { label: 'One-time', color: '#3b82f6' as const },
|
||||
recurring: { label: 'Recurring', color: '#8b5cf6' as const },
|
||||
setup: { label: 'Setup', color: '#10b981' as const },
|
||||
unknown: { label: 'Task', color: '#6b7280' as const },
|
||||
};
|
||||
const c = config[type];
|
||||
return <Chip label={c.label} size="small" sx={{ bgcolor: `${c.color}18`, color: c.color, fontWeight: 600, fontSize: '0.7rem' }} />;
|
||||
}
|
||||
|
||||
function formatCompletedAt(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export const TaskSchedulingPanel: React.FC<TaskSchedulingPanelProps> = ({
|
||||
scheduledTasks,
|
||||
failedTasks,
|
||||
personaGenerated,
|
||||
completedAt,
|
||||
}) => {
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
const totalTasks = scheduledTasks.length + failedTasks.length;
|
||||
const successRate = totalTasks > 0 ? Math.round((scheduledTasks.length / totalTasks) * 100) : 100;
|
||||
|
||||
return (
|
||||
<Zoom in={true} timeout={800}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: failedTasks.length > 0
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)'
|
||||
: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: failedTasks.length > 0
|
||||
? '1px solid rgba(245, 158, 11, 0.3)'
|
||||
: '1px solid rgba(16, 185, 129, 0.25)',
|
||||
borderRadius: 3,
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{failedTasks.length > 0 ? (
|
||||
<Autorenew sx={{ color: 'warning.main', fontSize: 30 }} />
|
||||
) : (
|
||||
<RocketLaunch sx={{ color: 'success.main', fontSize: 30 }} />
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{failedTasks.length > 0 ? 'Setup Tasks Scheduled' : 'All Tasks Launched!'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#475569' }}>
|
||||
{completedAt ? `Completed at ${formatCompletedAt(completedAt)}` : 'Onboarding complete'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
icon={<CheckCircle sx={{ fontSize: 16 }} />}
|
||||
label={`${scheduledTasks.length} Scheduled`}
|
||||
color="success"
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
{failedTasks.length > 0 && (
|
||||
<Chip
|
||||
icon={<ErrorOutline sx={{ fontSize: 16 }} />}
|
||||
label={`${failedTasks.length} Failed`}
|
||||
color="warning"
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={`${successRate}% Success`}
|
||||
sx={{
|
||||
bgcolor: successRate === 100 ? '#ecfdf5' : '#fef3c7',
|
||||
color: successRate === 100 ? '#059669' : '#d97706',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600 }}>Task Scheduling Progress</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>{scheduledTasks.length}/{totalTasks}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={successRate}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
bgcolor: successRate === 100 ? '#10b981' : '#f59e0b',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{personaGenerated && (
|
||||
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
AI Persona Generated
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Your brand persona was generated during setup. Your agents will use this for personalized content.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!personaGenerated && (
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Persona Generation Scheduled
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Your brand persona is being generated in the background. This typically takes 5-10 minutes after launch.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{failedTasks.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Some Tasks Could Not Be Scheduled
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{failedTasks.length} task(s) failed to schedule. These will be retried automatically by the scheduler. You can also retry from the Team Activity page.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
sx={{ color: '#475569' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, mr: 0.5 }}>
|
||||
{showDetails ? 'Hide' : 'Show'} task details
|
||||
</Typography>
|
||||
{showDetails ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showDetails}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, color: '#0f172a' }}>
|
||||
Scheduled Tasks ({scheduledTasks.length})
|
||||
</Typography>
|
||||
{scheduledTasks.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic', pl: 2 }}>
|
||||
No tasks were scheduled.
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense sx={{ bgcolor: 'rgba(255,255,255,0.6)', borderRadius: 2, mb: 2 }}>
|
||||
{scheduledTasks.map((taskKey) => (
|
||||
<ListItem key={taskKey} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircle sx={{ color: '#10b981', fontSize: 18 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={getTaskLabel(taskKey)}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#1e293b' }}
|
||||
/>
|
||||
{getTaskTypeChip(getTaskType(taskKey))}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{failedTasks.length > 0 && (
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, color: '#92400e' }}>
|
||||
Failed Tasks ({failedTasks.length})
|
||||
</Typography>
|
||||
<List dense sx={{ bgcolor: 'rgba(254,243,199,0.5)', borderRadius: 2 }}>
|
||||
{failedTasks.map((ft, idx) => (
|
||||
<ListItem key={idx} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<ErrorOutline sx={{ color: '#d97706', fontSize: 18 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={getTaskLabel(ft.task)}
|
||||
secondary={ft.error || 'Unknown error'}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 600, color: '#92400e' }}
|
||||
secondaryTypographyProps={{ variant: 'caption', color: '#78716c' }}
|
||||
/>
|
||||
{getTaskTypeChip(getTaskType(ft.task))}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSchedulingPanel;
|
||||
@@ -1,4 +1,6 @@
|
||||
export { default as SetupSummary } from './SetupSummary';
|
||||
export { default as CapabilitiesOverview } from './CapabilitiesOverview';
|
||||
export { default as AgentTeamSection } from './AgentTeamSection';
|
||||
export { default as TaskSchedulingPanel } from './TaskSchedulingPanel';
|
||||
export type { TaskSchedulingPanelProps, ScheduledTask } from './TaskSchedulingPanel';
|
||||
|
||||
|
||||
@@ -22,3 +22,12 @@ export interface FinalStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingCompletionResult {
|
||||
message: string;
|
||||
completed_at: string;
|
||||
completion_percentage: number;
|
||||
persona_generated: boolean;
|
||||
scheduled_tasks: string[];
|
||||
failed_tasks: Array<{ task: string; error: string }> | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
import { WIX_CLIENT_ID, getWixRedirectOrigin, getWixTrustedOrigins } from '../../../config/wixConfig';
|
||||
import { markConnectionHandled, isAlreadyHandled, clearConnectionHandled } from '../../../utils/wixConnectionDedup';
|
||||
|
||||
export const usePlatformConnections = () => {
|
||||
const [connectedPlatforms, setConnectedPlatforms] = useState<string[]>([]);
|
||||
@@ -7,15 +9,15 @@ export const usePlatformConnections = () => {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
// Handle Wix OAuth popup messages
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const trusted = [window.location.origin, ngrokOrigin];
|
||||
const trusted = getWixTrustedOrigins();
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
if (isAlreadyHandled()) return;
|
||||
markConnectionHandled();
|
||||
setConnectedPlatforms(prev => {
|
||||
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
|
||||
return updated;
|
||||
@@ -36,6 +38,8 @@ export const usePlatformConnections = () => {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
if (isAlreadyHandled()) return;
|
||||
markConnectionHandled();
|
||||
setConnectedPlatforms(prev => {
|
||||
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
|
||||
return updated;
|
||||
@@ -47,6 +51,7 @@ export const usePlatformConnections = () => {
|
||||
|
||||
const handleWixConnect = async () => {
|
||||
try {
|
||||
clearConnectionHandled();
|
||||
// Store current page URL BEFORE redirecting (critical for proper redirect back)
|
||||
// This ensures we can redirect back to the correct page (e.g., Blog Writer) after OAuth
|
||||
// Only store if not already set (allows WixConnectModal to override if needed)
|
||||
@@ -60,22 +65,25 @@ export const usePlatformConnections = () => {
|
||||
// Ignore storage errors
|
||||
}
|
||||
|
||||
if (!WIX_CLIENT_ID) {
|
||||
throw new Error('WIX_CLIENT_ID is not configured. Please check your .env file and restart the dev server.');
|
||||
}
|
||||
console.log('[handleWixConnect] Using WIX_CLIENT_ID:', WIX_CLIENT_ID.substring(0, 8) + '...');
|
||||
|
||||
// Use the working Wix OAuth flow from WixTestPage
|
||||
const wixClient = createClient({
|
||||
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
|
||||
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID })
|
||||
});
|
||||
|
||||
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
|
||||
const redirectOrigin = getWixRedirectOrigin();
|
||||
const redirectUri = `${redirectOrigin}/wix/callback`;
|
||||
console.log('[handleWixConnect] Redirect URI:', redirectUri);
|
||||
|
||||
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
|
||||
|
||||
|
||||
// Persist OAuth data robustly so callback can always recover it
|
||||
// 1) SessionStorage for same-origin same-tab flows
|
||||
try { sessionStorage.setItem('wix_oauth_data', JSON.stringify(oauthData)); } catch {}
|
||||
// 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 {
|
||||
const redirectTo = sessionStorage.getItem('wix_oauth_redirect') || window.location.href;
|
||||
console.log('[handleWixConnect] Storing redirect_to in window.name:', redirectTo);
|
||||
@@ -83,10 +91,23 @@ export const usePlatformConnections = () => {
|
||||
} catch (e) {
|
||||
console.error('[handleWixConnect] Failed to set window.name:', e);
|
||||
}
|
||||
|
||||
console.log('[handleWixConnect] Generating auth URL...');
|
||||
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
|
||||
console.log('[handleWixConnect] Auth URL generated, redirecting...');
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Wix connection error:', error);
|
||||
const message = error?.message || 'Unknown error during Wix connection';
|
||||
if (message.includes('System error occurred')) {
|
||||
throw new Error(
|
||||
`Wix SDK failed to generate auth URL. Common causes:\n` +
|
||||
`1. WIX_CLIENT_ID is missing or invalid (current: ${WIX_CLIENT_ID ? 'set' : 'EMPTY'})\n` +
|
||||
`2. The redirect URI (${getWixRedirectOrigin()}/wix/callback) is not registered in your Wix app\n` +
|
||||
`3. The Wix app does not have OAuth enabled\n` +
|
||||
`Original error: ${message}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -69,6 +70,7 @@ import { AdvertoolsInsights } from './components/AdvertoolsInsights';
|
||||
import SemanticHealthCard from './components/SemanticHealthCard';
|
||||
import SemanticInsights from './components/SemanticInsights';
|
||||
import KeywordGapAnalysis from './components/KeywordGapAnalysis';
|
||||
import ContentGapRadarCard from './components/ContentGapRadarCard';
|
||||
|
||||
// Phase 2A: Enterprise SEO Analysis
|
||||
import SEOAnalysisController from './SEOAnalysisController';
|
||||
@@ -118,7 +120,19 @@ const SEODashboard: React.FC = () => {
|
||||
|
||||
// Dashboard Tab State for Enterprise Analysis
|
||||
const [dashboardTab, setDashboardTab] = useState<number>(0);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
// Hash-based deep-link scroll (e.g. #content-gap-radar from workflow tasks)
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
const id = location.hash.replace('#', '');
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }), 300);
|
||||
}
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
// Competitor analysis data from onboarding step 3
|
||||
const [competitorAnalysisData, setCompetitorAnalysisData] = useState<any>(null);
|
||||
const [deepCompetitorAnalysisData, setDeepCompetitorAnalysisData] = useState<any>(null);
|
||||
@@ -933,6 +947,11 @@ const SEODashboard: React.FC = () => {
|
||||
{/* Keyword Gap Analysis */}
|
||||
<KeywordGapAnalysis />
|
||||
|
||||
{/* Content Gap Radar */}
|
||||
<Box id="content-gap-radar">
|
||||
<ContentGapRadarCard />
|
||||
</Box>
|
||||
|
||||
{/* Full Site Technical SEO Audit (from onboarding background job) */}
|
||||
{data.technical_seo_audit && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
LinearProgress,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Explore as ExploreIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Search as SearchIcon,
|
||||
Store as StoreIcon,
|
||||
Speed as SpeedIcon,
|
||||
Flag as FlagIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ContentCopy as ContentCopyIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
import { apiClient } from '../../../api/client';
|
||||
|
||||
interface ScoringBreakdown {
|
||||
gap_size: number;
|
||||
volume: number;
|
||||
trend: number;
|
||||
intent: number;
|
||||
competition: number;
|
||||
}
|
||||
|
||||
interface GapItem {
|
||||
topic: string;
|
||||
roi_score: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
recommended_action: string;
|
||||
scoring: ScoringBreakdown;
|
||||
sif_gap?: any;
|
||||
serp_evidence?: {
|
||||
competitors_found: Array<{ domain: string; title: string; url: string; snippet: string }>;
|
||||
competitor_count: number;
|
||||
domains_with_content: string[];
|
||||
} | null;
|
||||
competitor_content?: any;
|
||||
}
|
||||
|
||||
interface GapRadarData {
|
||||
gaps: GapItem[];
|
||||
summary: {
|
||||
total_topics_analyzed: number;
|
||||
high_priority: number;
|
||||
medium_priority: number;
|
||||
low_priority: number;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ContentBrief {
|
||||
titles: string[];
|
||||
outline: Array<{ heading: string; key_points: string[] }>;
|
||||
keywords: string[];
|
||||
angle: string;
|
||||
word_count: number;
|
||||
}
|
||||
|
||||
const priorityColor = (p: string): string => {
|
||||
switch (p) {
|
||||
case 'high': return '#ef4444';
|
||||
case 'medium': return '#f59e0b';
|
||||
default: return '#22c55e';
|
||||
}
|
||||
};
|
||||
|
||||
const roiBarColor = (score: number): string => {
|
||||
if (score >= 0.6) return '#22c55e';
|
||||
if (score >= 0.3) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
};
|
||||
|
||||
const roiLabel = (score: number): string => {
|
||||
if (score >= 0.6) return 'High Opportunity';
|
||||
if (score >= 0.3) return 'Moderate Opportunity';
|
||||
return 'Low Priority';
|
||||
};
|
||||
|
||||
const scoringConfig = [
|
||||
{ key: 'gap_size', label: 'Gap Size', icon: <SearchIcon sx={{ fontSize: 14 }} />, color: '#90CAF9' },
|
||||
{ key: 'volume', label: 'Search Volume', icon: <TrendingUpIcon sx={{ fontSize: 14 }} />, color: '#22c55e' },
|
||||
{ key: 'trend', label: 'Trend Momentum', icon: <SpeedIcon sx={{ fontSize: 14 }} />, color: '#f59e0b' },
|
||||
{ key: 'intent', label: 'Intent Score', icon: <FlagIcon sx={{ fontSize: 14 }} />, color: '#CE93D8' },
|
||||
{ key: 'competition', label: 'Competition', icon: <StoreIcon sx={{ fontSize: 14 }} />, color: '#ef4444' },
|
||||
];
|
||||
|
||||
const ContentGapRadarCard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<GapRadarData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [generatingTopic, setGeneratingTopic] = useState<string | null>(null);
|
||||
const [briefResult, setBriefResult] = useState<{ brief: ContentBrief; asset_id: number | null } | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (bypassCache = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: any = {};
|
||||
if (bypassCache) params.bypass_cache = 'true';
|
||||
const resp = await apiClient.get('/api/seo-dashboard/content-gap-radar', { params });
|
||||
setData(resp.data);
|
||||
if (resp.data.error) setError(resp.data.error);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || 'Failed to load content gap radar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const generateContent = useCallback(async (gap: GapItem) => {
|
||||
try {
|
||||
setGeneratingTopic(gap.topic);
|
||||
setBriefResult(null);
|
||||
const resp = await apiClient.post('/api/seo-dashboard/content-gap-radar/generate-content', {
|
||||
topic: gap.topic,
|
||||
recommended_action: gap.recommended_action,
|
||||
scoring: gap.scoring,
|
||||
serp_evidence: gap.serp_evidence,
|
||||
sif_gap: gap.sif_gap,
|
||||
});
|
||||
setBriefResult(resp.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || 'Failed to generate content brief');
|
||||
} finally {
|
||||
setGeneratingTopic(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenBlogWriter = useCallback(() => {
|
||||
if (briefResult?.asset_id) {
|
||||
navigate('/blog-writer', { state: { restoreBlogAssetId: briefResult.asset_id } });
|
||||
} else {
|
||||
navigate('/blog-writer');
|
||||
}
|
||||
}, [briefResult, navigate]);
|
||||
|
||||
const handleCopyBrief = useCallback(() => {
|
||||
if (!briefResult?.brief) return;
|
||||
const b = briefResult.brief;
|
||||
const text = [
|
||||
`## Content Brief\n`,
|
||||
`### Titles\n${b.titles.map((t, i) => `${i + 1}. ${t}`).join('\n')}\n`,
|
||||
`### Angle\n${b.angle}\n`,
|
||||
`### Keywords\n${b.keywords.join(', ')}\n`,
|
||||
`### Outline\n${b.outline.map(s => `- ${s.heading}\n${s.key_points.map(kp => ` - ${kp}`).join('\n')}`).join('\n')}`,
|
||||
`\nTarget: ~${b.word_count} words`,
|
||||
].join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
}, [briefResult]);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<GlassCard sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', mt: 1, display: 'block' }}>
|
||||
Scanning content gaps...
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
const hasGaps = data?.gaps && data.gaps.length > 0;
|
||||
const summary = data?.summary;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ExploreIcon sx={{ color: 'white', fontSize: 20 }} />
|
||||
<Typography variant="h6" fontWeight={700} sx={{ color: 'white', flex: 1 }}>
|
||||
Content Gap Radar
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
fontSize: '0.7rem',
|
||||
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}}
|
||||
>
|
||||
{loading ? 'Scanning...' : 'Refresh'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && !hasGaps && (
|
||||
<GlassCard sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)' }}>{error}</Typography>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{data?.message && !hasGaps && (
|
||||
<GlassCard sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>{data.message}</Typography>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{loading && hasGaps && (
|
||||
<LinearProgress sx={{ mb: 2, borderRadius: 1, bgcolor: 'rgba(255,255,255,0.05)', '& .MuiLinearProgress-bar': { bgcolor: '#2196F3' } }} />
|
||||
)}
|
||||
|
||||
{summary && summary.total_topics_analyzed > 0 && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1.5, mb: 2 }}>
|
||||
<GlassCard sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Topics Analyzed</Typography>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: 'white' }}>{summary.total_topics_analyzed}</Typography>
|
||||
</GlassCard>
|
||||
<GlassCard sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>High Priority</Typography>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#ef4444' }}>{summary.high_priority}</Typography>
|
||||
</GlassCard>
|
||||
<GlassCard sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Medium Priority</Typography>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#f59e0b' }}>{summary.medium_priority}</Typography>
|
||||
</GlassCard>
|
||||
<GlassCard sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>Low Priority</Typography>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ color: '#22c55e' }}>{summary.low_priority}</Typography>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasGaps && data!.gaps.map((gap, i) => (
|
||||
<Accordion
|
||||
key={gap.topic}
|
||||
defaultExpanded={i === 0}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '8px !important',
|
||||
mb: 1,
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.5)' }} />}>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<Box display="flex" alignItems="center" gap={1.5} mb={0.5}>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ color: 'white', flex: 1 }}>
|
||||
{gap.topic}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={gap.priority}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18, fontSize: '0.6rem', fontWeight: 700,
|
||||
bgcolor: `${priorityColor(gap.priority)}22`,
|
||||
color: priorityColor(gap.priority),
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`ROI ${(gap.roi_score * 100).toFixed(0)}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18, fontSize: '0.6rem', fontWeight: 700,
|
||||
bgcolor: `${roiBarColor(gap.roi_score)}22`,
|
||||
color: roiBarColor(gap.roi_score),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={generatingTopic === gap.topic ? <CircularProgress size={12} /> : <AutoAwesomeIcon />}
|
||||
onClick={(e) => { e.stopPropagation(); generateContent(gap); }}
|
||||
disabled={generatingTopic !== null}
|
||||
sx={{
|
||||
height: 24, minWidth: 0, px: 1, fontSize: '0.6rem', fontWeight: 600,
|
||||
color: '#CE93D8',
|
||||
borderColor: 'rgba(206,147,216,0.3)',
|
||||
whiteSpace: 'nowrap',
|
||||
'&:hover': { borderColor: '#CE93D8', bgcolor: 'rgba(206,147,216,0.08)' },
|
||||
}}
|
||||
>
|
||||
{generatingTopic === gap.topic ? 'Generating...' : 'Create Content'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box sx={{ flex: 1, height: 4, bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${Math.min(gap.roi_score * 100, 100)}%`, height: '100%', bgcolor: roiBarColor(gap.roi_score), borderRadius: 2 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0, fontSize: '0.6rem' }}>
|
||||
{roiLabel(gap.roi_score)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', display: 'block', mb: 1.5, fontStyle: 'italic' }}>
|
||||
{gap.recommended_action}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block' }}>
|
||||
Scoring Breakdown
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
{scoringConfig.map((s) => {
|
||||
const val = (gap.scoring as any)[s.key] ?? 0;
|
||||
return (
|
||||
<Box key={s.key} sx={{ textAlign: 'center', p: 0.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" gap={0.3} mb={0.3}>
|
||||
{s.icon}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.55rem' }}>{s.label}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" fontWeight={700} sx={{ color: s.color, fontSize: '0.8rem' }}>
|
||||
{(val * 100).toFixed(0)}%
|
||||
</Typography>
|
||||
<Box sx={{ height: 2, bgcolor: 'rgba(255,255,255,0.08)', borderRadius: 1, mt: 0.3, overflow: 'hidden' }}>
|
||||
<Box sx={{ width: `${val * 100}%`, height: '100%', bgcolor: s.color, borderRadius: 1 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{gap.serp_evidence && gap.serp_evidence.competitors_found?.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block' }}>
|
||||
Competitors Ranking — {gap.serp_evidence.competitor_count} results across {gap.serp_evidence.domains_with_content?.length || 0} domains
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
|
||||
{gap.serp_evidence.domains_with_content?.slice(0, 5).map((d: string) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ height: 18, fontSize: '0.55rem', bgcolor: 'rgba(33,150,243,0.12)', color: '#90CAF9' }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1 }}>
|
||||
{gap.serp_evidence.competitors_found.slice(0, 3).map((c: any, ci: number) => (
|
||||
<Box key={ci} sx={{ p: 0.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
|
||||
<Typography variant="caption" fontWeight={600} sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
{c.title || c.domain}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', display: 'block', fontSize: '0.55rem' }}>
|
||||
{c.snippet?.slice(0, 120)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{gap.sif_gap && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', display: 'block' }}>
|
||||
SIF gap: {gap.sif_gap.priority} priority · confidence {((gap.sif_gap.confidence ?? 0) * 100).toFixed(0)}% · delta {((gap.sif_gap.coverage_delta ?? 0) * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
|
||||
{/* Content Brief Dialog */}
|
||||
<Dialog
|
||||
open={briefResult !== null}
|
||||
onClose={() => setBriefResult(null)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: { bgcolor: '#1a1a2e', color: 'white', border: '1px solid rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, pb: 1 }}>
|
||||
<AutoAwesomeIcon sx={{ color: '#CE93D8', fontSize: 20 }} />
|
||||
<Typography variant="h6" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Content Brief
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setBriefResult(null)} sx={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
{briefResult && (
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
{/* Headline options */}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
|
||||
HEADLINE OPTIONS
|
||||
</Typography>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{briefResult.brief.titles.map((t, i) => (
|
||||
<Typography key={i} variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.3 }}>
|
||||
{i + 1}. {t}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
|
||||
|
||||
{/* Writing angle */}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
|
||||
STRATEGIC ANGLE
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 2, lineHeight: 1.6 }}>
|
||||
{briefResult.brief.angle}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
|
||||
|
||||
{/* Target keywords */}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
|
||||
TARGET KEYWORDS
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||
{briefResult.brief.keywords.map((kw) => (
|
||||
<Chip key={kw} label={kw} size="small" sx={{ height: 20, fontSize: '0.6rem', bgcolor: 'rgba(206,147,216,0.12)', color: '#CE93D8' }} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)', mb: 2 }} />
|
||||
|
||||
{/* Outline */}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mb: 0.5, display: 'block', fontWeight: 600 }}>
|
||||
OUTLINE — ~{briefResult.brief.word_count} words
|
||||
</Typography>
|
||||
{briefResult.brief.outline.map((section, i) => (
|
||||
<Box key={i} sx={{ mb: 1.5, p: 1, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ color: 'rgba(255,255,255,0.8)', mb: 0.5 }}>
|
||||
{section.heading}
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||
{section.key_points.map((kp, j) => (
|
||||
<Typography key={j} variant="caption" component="li" sx={{ color: 'rgba(255,255,255,0.5)', display: 'list-item', mb: 0.2 }}>
|
||||
{kp}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</DialogContent>
|
||||
)}
|
||||
|
||||
<DialogActions sx={{ p: 2, pt: 0, gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={handleCopyBrief}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
fontSize: '0.75rem',
|
||||
'&:hover': { borderColor: 'rgba(255,255,255,0.4)' },
|
||||
}}
|
||||
>
|
||||
Copy Brief
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={handleOpenBlogWriter}
|
||||
sx={{
|
||||
bgcolor: '#CE93D8',
|
||||
color: '#1a1a2e',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
'&:hover': { bgcolor: '#BA68C8' },
|
||||
}}
|
||||
>
|
||||
Open in Blog Writer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentGapRadarCard;
|
||||
@@ -14,4 +14,5 @@ export { default as SEOAnalysisError } from './SEOAnalysisError';
|
||||
export { default as PlatformStatus } from './PlatformStatus';
|
||||
export { default as AIInsightsPanel } from './AIInsightsPanel';
|
||||
export { default as MetricCard } from './MetricCard';
|
||||
export { default as HealthScore } from './HealthScore';
|
||||
export { default as HealthScore } from './HealthScore';
|
||||
export { default as ContentGapRadarCard } from './ContentGapRadarCard';
|
||||
161
frontend/src/components/TeamActivity/ActivityLog.tsx
Normal file
161
frontend/src/components/TeamActivity/ActivityLog.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Collapse,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Terminal as TerminalIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentRunItem, AgentEventItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface ActivityLogProps {
|
||||
runs: AgentRunItem[];
|
||||
events: AgentEventItem[];
|
||||
}
|
||||
|
||||
type Tab = 'runs' | 'events';
|
||||
|
||||
const ActivityLog: React.FC<ActivityLogProps> = ({ runs, events }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('runs');
|
||||
|
||||
const nonCommitteeEvents = useMemo(
|
||||
() => events.filter((e) => e.event_type !== 'committee_meeting'),
|
||||
[events],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
|
||||
}}
|
||||
>
|
||||
<TerminalIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)', fontWeight: 600, flex: 1, fontSize: 13 }}>
|
||||
Activity Log
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mr: 1 }}>
|
||||
<Chip
|
||||
label={`${runs.length} runs`}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.4)' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`${nonCommitteeEvents.length} events`}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.4)' }}
|
||||
/>
|
||||
</Box>
|
||||
{open ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
|
||||
<Collapse in={open}>
|
||||
<Box sx={{ px: 2, pb: 1.5 }}>
|
||||
{/* Tabs */}
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
value={tab}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setTab(v)}
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiToggleButton-root': {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
px: 1.5,
|
||||
py: 0.25,
|
||||
'&.Mui-selected': {
|
||||
color: '#8b9cf7',
|
||||
bgcolor: 'rgba(102,126,234,0.15)',
|
||||
borderColor: 'rgba(102,126,234,0.3)',
|
||||
},
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="runs">Runs</ToggleButton>
|
||||
<ToggleButton value="events">Events</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2 },
|
||||
}}
|
||||
>
|
||||
{tab === 'runs' && (
|
||||
runs.length === 0
|
||||
? <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', py: 2 }}>No runs recorded</Typography>
|
||||
: runs.slice(0, 50).map((run) => (
|
||||
<Box key={run.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 1, borderRadius: 1, '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' } }}>
|
||||
<Box sx={{
|
||||
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: run.status === 'completed' || run.success ? '#4caf50' : run.status === 'error' || run.success === false ? '#f44336' : run.status === 'running' ? '#2196f3' : '#9e9e9e',
|
||||
}} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', minWidth: 100, fontSize: 10, fontWeight: 600 }}>
|
||||
{run.agent_type || 'agent'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', flex: 1, fontSize: 10 }}>
|
||||
{run.status || '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', fontSize: 9, minWidth: 50, textAlign: 'right' }}>
|
||||
{run.finished_at ? new Date(run.finished_at).toLocaleTimeString() : run.started_at ? new Date(run.started_at).toLocaleTimeString() : '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
|
||||
{tab === 'events' && (
|
||||
nonCommitteeEvents.length === 0
|
||||
? <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', py: 2 }}>No events recorded</Typography>
|
||||
: nonCommitteeEvents.slice(0, 50).map((evt) => (
|
||||
<Box key={evt.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 1, borderRadius: 1, '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' } }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', minWidth: 100, fontSize: 10, fontWeight: 600 }}>
|
||||
{evt.agent_type || 'agent'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', flex: 1, fontSize: 10 }}>
|
||||
{evt.event_type}{evt.message ? `: ${evt.message}` : ''}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', fontSize: 9, minWidth: 50, textAlign: 'right' }}>
|
||||
{evt.created_at ? new Date(evt.created_at).toLocaleTimeString() : '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityLog;
|
||||
241
frontend/src/components/TeamActivity/AgentHelpModal.tsx
Normal file
241
frontend/src/components/TeamActivity/AgentHelpModal.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
HelpOutline as HelpIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Close as CloseIcon,
|
||||
SmartToy as AgentIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../api/agentsTeam';
|
||||
|
||||
const AGENT_DESCRIPTIONS: Record<string, { short: string; long: string }> = {
|
||||
content_strategy: {
|
||||
short: 'Orchestrates content pillars and strategy',
|
||||
long: 'The Content Strategy Agent defines your content pillars, target keywords, and content calendar. It ensures alignment across all content pieces to maintain a cohesive brand narrative.',
|
||||
},
|
||||
strategy_architect: {
|
||||
short: 'Builds strategic content plans',
|
||||
long: 'The Strategy Architect develops long-term content strategies, identifies market positioning opportunities, and creates data-driven plans that align with business objectives.',
|
||||
},
|
||||
seo_optimization: {
|
||||
short: 'Optimizes content for search engines',
|
||||
long: 'The SEO Agent analyzes search trends, identifies keyword opportunities, and ensures your content is optimized for discoverability. It handles on-page SEO, meta tags, and internal linking strategies.',
|
||||
},
|
||||
social_amplification: {
|
||||
short: 'Amplifies content across social channels',
|
||||
long: 'The Social Amplification Agent creates platform-specific social media adaptations of your content, schedules posts for optimal engagement, and monitors social signals.',
|
||||
},
|
||||
competitor: {
|
||||
short: 'Monitors competitor activity and strategy',
|
||||
long: 'The Competitor Agent continuously tracks competitor content, identifies content gaps, and provides strategic intelligence on competitor positioning, keywords, and audience targeting.',
|
||||
},
|
||||
content_gap_radar: {
|
||||
short: 'Detects content coverage gaps',
|
||||
long: 'The Content Gap Radar Agent identifies topics and keywords where your content is underperforming or missing. It surfaces opportunities to capture audience interest that competitors are neglecting.',
|
||||
},
|
||||
trend_surfer: {
|
||||
short: 'Surfaces trending opportunities',
|
||||
long: 'The Trend Surfer Agent monitors real-time search trends, social signals, and market movement. It surfaces opportunities with urgency ratings, impact scores, and suggested angles for content creation.',
|
||||
},
|
||||
content_guardian: {
|
||||
short: 'Quality watchdog over committee output',
|
||||
long: 'The Content Guardian Agent audits the committee\'s output after each daily workflow. It checks reasoning quality, identifies coverage gaps, flags overlaps, and generates alerts for systemic issues. It never proposes tasks — only audits.',
|
||||
},
|
||||
};
|
||||
|
||||
const SIF_DESCRIPTION = {
|
||||
short: 'Semantic Intelligence Framework — the orchestration layer',
|
||||
long: 'The SIF (Semantic Intelligence Framework) is ALwrity\'s orchestration layer for autonomous marketing agents. It coordinates the 6-member committee (ContentStrategy, StrategyArchitect, SEO, Social, Competitor, ContentGapRadar), plus TrendSurfer for signal detection and ContentGuardian for quality auditing. The SIF handles prompt sequencing, context card assembly, and committee voting.',
|
||||
};
|
||||
|
||||
const AgentHelpModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agents, setAgents] = useState<AgentTeamCatalogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(true);
|
||||
getAgentTeam()
|
||||
.then(setAgents)
|
||||
.catch(() => setAgents([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Learn about your AI agents" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ color: 'rgba(255,255,255,0.6)', '&:hover': { color: 'rgba(255,255,255,0.9)' } }}
|
||||
>
|
||||
<HelpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: '#1a1a2e',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<AgentIcon sx={{ color: '#7c3aed' }} />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)' }}>
|
||||
Your AI Marketing Team
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||
Powered by the SIF Agent Framework
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => setOpen(false)} sx={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Box sx={{ p: 2, mb: 2, borderRadius: 2, bgcolor: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.2)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#a78bfa', mb: 0.5 }}>
|
||||
SIF Agent Framework
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', lineHeight: 1.5 }}>
|
||||
{SIF_DESCRIPTION.long}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label="6 Committee Agents" size="small" sx={{ bgcolor: 'rgba(79,70,229,0.15)', color: '#818cf8', fontWeight: 600 }} />
|
||||
<Chip label="1 Trend Agent" size="small" sx={{ bgcolor: 'rgba(255,152,0,0.15)', color: '#ffb74d', fontWeight: 600 }} />
|
||||
<Chip label="1 Watchdog Agent" size="small" sx={{ bgcolor: 'rgba(76,175,80,0.15)', color: '#81c784', fontWeight: 600 }} />
|
||||
</Box>
|
||||
|
||||
{loading && (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', py: 3 }}>
|
||||
Loading agent details...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!loading && agents.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{agents.map((agent) => {
|
||||
const desc = AGENT_DESCRIPTIONS[agent.agent_key];
|
||||
const displayName = agent.profile?.display_name || agent.defaults?.display_name_template?.replace('{website_name}', 'Your') || agent.role || agent.agent_key;
|
||||
const enabled = agent.profile?.enabled ?? agent.defaults?.enabled ?? true;
|
||||
const schedule = agent.profile?.schedule?.mode || agent.defaults?.schedule?.mode || 'on_demand';
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={agent.agent_key}
|
||||
disableGutters
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: '8px !important',
|
||||
'&:before': { display: 'none' },
|
||||
'&.Mui-expanded': { bgcolor: 'rgba(255,255,255,0.06)', margin: 0 },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.3)' }} />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', pr: 1 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: enabled ? '#4caf50' : '#6b7280', flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)' }} noWrap>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)' }} noWrap>
|
||||
{desc?.short || agent.agent_key}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={schedule === 'on_demand' ? 'On-demand' : schedule}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.65)', lineHeight: 1.6, mb: 1.5 }}>
|
||||
{desc?.long || 'This agent contributes to your automated marketing workflow.'}
|
||||
</Typography>
|
||||
{agent.responsibilities.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>
|
||||
Responsibilities
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{agent.responsibilities.map((r, i) => (
|
||||
<Chip key={i} label={r} size="small" sx={{ height: 20, fontSize: 9, bgcolor: 'rgba(255,255,255,0.04)', color: 'rgba(255,255,255,0.55)' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{agent.tools.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>
|
||||
Tools
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{agent.tools.map((t, i) => (
|
||||
<Chip key={i} label={t} size="small" variant="outlined" sx={{ height: 20, fontSize: 9, borderColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.45)' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && agents.length === 0 && (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', py: 3 }}>
|
||||
Complete onboarding to configure your agent team.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 1.5 }}>
|
||||
<Button onClick={() => setOpen(false)} sx={{ textTransform: 'none', color: 'rgba(255,255,255,0.7)' }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentHelpModal;
|
||||
358
frontend/src/components/TeamActivity/AgentStatusPanel.tsx
Normal file
358
frontend/src/components/TeamActivity/AgentStatusPanel.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Collapse,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
WarningAmber as WarningAmberIcon,
|
||||
Error as ErrorIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentEventItem, AgentRunItem, AgentAlertItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface CommitteeProposal {
|
||||
agent: string;
|
||||
title: string;
|
||||
pillar_id: string;
|
||||
priority: string;
|
||||
valid: boolean;
|
||||
accepted: boolean;
|
||||
reasoning?: string;
|
||||
rejected_reason?: string | null;
|
||||
estimated_time?: number;
|
||||
action_type?: string;
|
||||
}
|
||||
|
||||
interface CommitteePayload {
|
||||
agents_polled: number;
|
||||
total_proposals: number;
|
||||
accepted_count: number;
|
||||
rejected_count: number;
|
||||
proposals: CommitteeProposal[];
|
||||
}
|
||||
|
||||
// Agent type mapping: source_agent → agent_type for run cross-ref
|
||||
const AGENT_TYPE_MAP: Record<string, string> = {
|
||||
ContentStrategyAgent: 'content_strategist',
|
||||
StrategyArchitectAgent: 'strategy_architect',
|
||||
SEOOptimizationAgent: 'seo_specialist',
|
||||
SocialAmplificationAgent: 'social_media_manager',
|
||||
CompetitorResponseAgent: 'competitor_analyst',
|
||||
ContentGapRadarAgent: 'content_gap_radar',
|
||||
};
|
||||
|
||||
const AGENT_INFO: Record<string, { label: string; short: string; desc: string }> = {
|
||||
ContentStrategyAgent: { label: 'Content Strategy', short: 'Strategy', desc: 'Content planning based on your pillars & topics' },
|
||||
StrategyArchitectAgent: { label: 'Strategy Architect', short: 'Architect', desc: 'Semantic gap discovery from your content index' },
|
||||
SEOOptimizationAgent: { label: 'SEO Optimization', short: 'SEO', desc: 'Technical SEO, keywords & performance' },
|
||||
SocialAmplificationAgent: { label: 'Social Amplification', short: 'Social', desc: 'Social media distribution & engagement' },
|
||||
CompetitorResponseAgent: { label: 'Competitor Response', short: 'Competitor', desc: 'Competitor content monitoring & response' },
|
||||
ContentGapRadarAgent: { label: 'Content Gap Radar', short: 'Gap Radar', desc: 'ROI-ranked content gap opportunities' },
|
||||
};
|
||||
|
||||
type AgentHealth = 'good' | 'warning' | 'error' | 'inactive';
|
||||
|
||||
interface AgentStatus {
|
||||
sourceName: string; // class name (from proposals)
|
||||
agentType: string; // agent_type value (from runs)
|
||||
label: string;
|
||||
short: string;
|
||||
desc: string;
|
||||
health: AgentHealth;
|
||||
healthReason: string;
|
||||
// From committee
|
||||
totalProposals: number;
|
||||
acceptedProposals: number;
|
||||
proposals: CommitteeProposal[];
|
||||
// From runs
|
||||
latestRun: AgentRunItem | null;
|
||||
// From alerts
|
||||
alertCount: number;
|
||||
}
|
||||
|
||||
const healthIcon = (h: AgentHealth) => {
|
||||
if (h === 'good') return <CheckCircleIcon sx={{ fontSize: 20, color: '#4caf50' }} />;
|
||||
if (h === 'warning') return <WarningAmberIcon sx={{ fontSize: 20, color: '#ff9800' }} />;
|
||||
return <ErrorIcon sx={{ fontSize: 20, color: '#f44336' }} />;
|
||||
};
|
||||
|
||||
const healthColor = (h: AgentHealth) => {
|
||||
if (h === 'good') return '#4caf50';
|
||||
if (h === 'warning') return '#ff9800';
|
||||
return '#f44336';
|
||||
};
|
||||
|
||||
// ─── Agent Card ────────────────────────────────────
|
||||
const AgentCard: React.FC<{
|
||||
status: AgentStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
latestRuns: AgentRunItem[];
|
||||
}> = ({ status, expanded, onToggle, latestRuns }) => {
|
||||
const color = healthColor(status.health);
|
||||
const pct = status.totalProposals > 0 ? (status.acceptedProposals / status.totalProposals) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${color}22`,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s, border-color 0.2s',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.08)', borderColor: `${color}44` },
|
||||
}}
|
||||
>
|
||||
{healthIcon(status.health)}
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 700, fontSize: 13, lineHeight: 1.3 }}>
|
||||
{status.label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25 }}>
|
||||
{status.totalProposals > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 10 }}>
|
||||
{status.acceptedProposals}/{status.totalProposals} proposals
|
||||
</Typography>
|
||||
)}
|
||||
{status.alertCount > 0 && (
|
||||
<Chip
|
||||
label={`${status.alertCount} alert${status.alertCount > 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: 9, fontWeight: 700, bgcolor: 'rgba(244,67,54,0.15)', color: '#f44336' }}
|
||||
/>
|
||||
)}
|
||||
{status.latestRun && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', fontSize: 10, display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<ScheduleIcon sx={{ fontSize: 10 }} />
|
||||
{timeAgo(status.latestRun.finished_at || status.latestRun.started_at)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mini bar for proposal acceptance */}
|
||||
{status.totalProposals > 0 && (
|
||||
<Box sx={{ width: 40 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: color },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
|
||||
{/* Expanded details */}
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ pl: 1.5, pr: 1.5, pb: 1, pt: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, mb: 0.5, display: 'block' }}>
|
||||
{status.desc}
|
||||
</Typography>
|
||||
|
||||
{/* Proposals from this agent */}
|
||||
{status.proposals.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Proposals
|
||||
</Typography>
|
||||
{status.proposals.map((p, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.3, px: 1, borderRadius: 1, bgcolor: p.accepted ? 'rgba(76,175,80,0.06)' : 'transparent' }}>
|
||||
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.7)', fontSize: 11 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip label={p.pillar_id} size="small" sx={{
|
||||
height: 18, fontSize: 9, fontWeight: 600,
|
||||
bgcolor: p.valid ? 'rgba(102,126,234,0.15)' : 'rgba(244,67,54,0.15)',
|
||||
color: p.valid ? '#8b9cf7' : '#f44336',
|
||||
}} />
|
||||
<Chip label={p.priority} size="small" sx={{
|
||||
height: 18, fontSize: 9, fontWeight: 600, textTransform: 'capitalize',
|
||||
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
|
||||
}} />
|
||||
<Chip label={p.accepted ? '✓' : '—'} size="small" sx={{
|
||||
height: 18, fontSize: 9, fontWeight: 700,
|
||||
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.accepted ? '#4caf50' : '#9e9e9e',
|
||||
}} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Latest runs */}
|
||||
{latestRuns.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
Recent Runs
|
||||
</Typography>
|
||||
{latestRuns.slice(0, 3).map((run) => (
|
||||
<Box key={run.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.3, px: 1 }}>
|
||||
<Box sx={{
|
||||
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: run.status === 'completed' || run.success ? '#4caf50' : run.status === 'error' || run.success === false ? '#f44336' : '#ff9800',
|
||||
}} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontSize: 10, flex: 1 }}>
|
||||
{run.status || 'running'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9 }}>
|
||||
{timeAgo(run.finished_at || run.started_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main Component ─────────────────────────────────
|
||||
const AgentStatusPanel: React.FC<{
|
||||
events: AgentEventItem[];
|
||||
runs: AgentRunItem[];
|
||||
alerts: AgentAlertItem[];
|
||||
}> = ({ events, runs, alerts }) => {
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
|
||||
const agents = useMemo<AgentStatus[]>(() => {
|
||||
// Parse committee meeting data
|
||||
const meeting = events.find((e) => e.event_type === 'committee_meeting');
|
||||
const payload = meeting?.payload
|
||||
? (typeof meeting.payload === 'string' ? JSON.parse(meeting.payload) : meeting.payload) as CommitteePayload
|
||||
: null;
|
||||
|
||||
// Group proposals by agent
|
||||
const proposalMap = new Map<string, CommitteeProposal[]>();
|
||||
if (payload) {
|
||||
for (const p of payload.proposals) {
|
||||
if (!proposalMap.has(p.agent)) proposalMap.set(p.agent, []);
|
||||
proposalMap.get(p.agent)!.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Build agent status list from known agents
|
||||
const agentKeys = Object.keys(AGENT_INFO);
|
||||
const result: AgentStatus[] = [];
|
||||
|
||||
for (const sourceName of agentKeys) {
|
||||
const info = AGENT_INFO[sourceName];
|
||||
const agentType = AGENT_TYPE_MAP[sourceName];
|
||||
const proposals = proposalMap.get(sourceName) || [];
|
||||
|
||||
// Proposals stats
|
||||
const totalProposals = proposals.length;
|
||||
const acceptedProposals = proposals.filter((p) => p.accepted).length;
|
||||
|
||||
// Latest run
|
||||
const agentRuns = runs.filter((r) => r.agent_type === agentType);
|
||||
const latestRun = agentRuns.length > 0 ? agentRuns[0] : null;
|
||||
|
||||
// Alerts
|
||||
const alertCount = alerts.filter((a) => a.title && a.title.includes(info.short)).length;
|
||||
|
||||
// Determine health
|
||||
let health: AgentHealth = 'good';
|
||||
let healthReason = 'All systems good';
|
||||
|
||||
if (latestRun?.status === 'error' || latestRun?.success === false) {
|
||||
health = 'error';
|
||||
healthReason = 'Latest run failed';
|
||||
} else if (alertCount > 0) {
|
||||
health = 'warning';
|
||||
healthReason = `${alertCount} alert${alertCount > 1 ? 's' : ''}`;
|
||||
} else if (totalProposals > 0 && acceptedProposals === 0) {
|
||||
health = 'warning';
|
||||
healthReason = 'All proposals rejected';
|
||||
} else if (totalProposals > 0 && acceptedProposals < totalProposals) {
|
||||
health = 'warning';
|
||||
healthReason = `${totalProposals - acceptedProposals} proposal${totalProposals - acceptedProposals > 1 ? 's' : ''} not adopted`;
|
||||
}
|
||||
|
||||
result.push({
|
||||
sourceName,
|
||||
agentType,
|
||||
...info,
|
||||
health,
|
||||
healthReason,
|
||||
totalProposals,
|
||||
acceptedProposals,
|
||||
proposals,
|
||||
latestRun,
|
||||
alertCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: error first, then warning, then good, then inactive
|
||||
const healthRank = { error: 0, warning: 1, good: 2, inactive: 3 };
|
||||
result.sort((a, b) => healthRank[a.health] - healthRank[b.health]);
|
||||
|
||||
return result;
|
||||
}, [events, runs, alerts]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Agent Status
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1 }}>
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.sourceName}
|
||||
status={agent}
|
||||
expanded={expandedAgent === agent.sourceName}
|
||||
onToggle={() => setExpandedAgent(expandedAgent === agent.sourceName ? null : agent.sourceName)}
|
||||
latestRuns={runs.filter((r) => r.agent_type === agent.agentType)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function timeAgo(dateStr?: string | null): string {
|
||||
if (!dateStr) return '—';
|
||||
const ms = Date.now() - new Date(dateStr).getTime();
|
||||
const min = Math.floor(ms / 60000);
|
||||
if (min < 1) return 'just now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default AgentStatusPanel;
|
||||
167
frontend/src/components/TeamActivity/AlertBanner.tsx
Normal file
167
frontend/src/components/TeamActivity/AlertBanner.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
WarningAmber as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { AgentAlertItem, AgentApprovalItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface AlertBannerProps {
|
||||
alerts: AgentAlertItem[];
|
||||
approvals: AgentApprovalItem[];
|
||||
}
|
||||
|
||||
const severityIcon = (sev?: string) => {
|
||||
if (sev === 'error' || sev === 'critical') return <ErrorIcon sx={{ fontSize: 18, color: '#f44336' }} />;
|
||||
if (sev === 'warning') return <WarningIcon sx={{ fontSize: 18, color: '#ff9800' }} />;
|
||||
return <InfoIcon sx={{ fontSize: 18, color: '#2196f3' }} />;
|
||||
};
|
||||
|
||||
const severityBg = (sev?: string) => {
|
||||
if (sev === 'error' || sev === 'critical') return 'rgba(244,67,54,0.1)';
|
||||
if (sev === 'warning') return 'rgba(255,152,0,0.1)';
|
||||
return 'rgba(33,150,243,0.1)';
|
||||
};
|
||||
|
||||
const severityBorder = (sev?: string) => {
|
||||
if (sev === 'error' || sev === 'critical') return 'rgba(244,67,54,0.25)';
|
||||
if (sev === 'warning') return 'rgba(255,152,0,0.25)';
|
||||
return 'rgba(33,150,243,0.25)';
|
||||
};
|
||||
|
||||
const AlertBanner: React.FC<AlertBannerProps> = ({ alerts, approvals }) => {
|
||||
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||
const [dismissing, setDismissing] = useState<Set<number>>(new Set());
|
||||
const [approvalsOpen, setApprovalsOpen] = useState(false);
|
||||
|
||||
const handleDismiss = async (alertId: number) => {
|
||||
if (dismissing.has(alertId)) return;
|
||||
setDismissing((s) => new Set(s).add(alertId));
|
||||
try {
|
||||
await apiClient.post(`/api/agents/alerts/${alertId}/mark-read`);
|
||||
setDismissed((s) => new Set(s).add(alertId));
|
||||
} catch {
|
||||
setDismissed((s) => new Set(s).add(alertId));
|
||||
} finally {
|
||||
setDismissing((s) => { const next = new Set(s); next.delete(alertId); return next; });
|
||||
}
|
||||
};
|
||||
|
||||
const unreadAlerts = useMemo(
|
||||
() => alerts.filter((a) => a.id && !dismissed.has(a.id)),
|
||||
[alerts, dismissed],
|
||||
);
|
||||
|
||||
const pendingApprovals = useMemo(
|
||||
() => approvals.filter((a) => a.status === 'pending'),
|
||||
[approvals],
|
||||
);
|
||||
|
||||
if (unreadAlerts.length === 0 && pendingApprovals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{/* Alerts */}
|
||||
{unreadAlerts.slice(0, 5).map((alert) => (
|
||||
<Box
|
||||
key={alert.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: severityBg(alert.severity),
|
||||
border: `1px solid ${severityBorder(alert.severity)}`,
|
||||
}}
|
||||
>
|
||||
{severityIcon(alert.severity)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 600, fontSize: 13 }}>
|
||||
{alert.title || 'Alert'}
|
||||
</Typography>
|
||||
{alert.message && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
|
||||
{alert.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={dismissing.has(alert.id!)}
|
||||
onClick={() => alert.id && handleDismiss(alert.id)}
|
||||
sx={{ color: 'rgba(255,255,255,0.3)', '&:hover': { color: 'rgba(255,255,255,0.6)' }, '&.Mui-disabled': { opacity: 0.3 } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Pending approvals */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(102,126,234,0.1)',
|
||||
border: '1px solid rgba(102,126,234,0.2)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setApprovalsOpen(!approvalsOpen)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.03)' },
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon sx={{ fontSize: 18, color: '#8b9cf7' }} />
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', fontWeight: 600, flex: 1, fontSize: 13 }}>
|
||||
{pendingApprovals.length} approval{pendingApprovals.length > 1 ? 's' : ''} pending
|
||||
</Typography>
|
||||
<Chip
|
||||
label={pendingApprovals.length}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: 10, fontWeight: 700, bgcolor: 'rgba(102,126,234,0.2)', color: '#8b9cf7' }}
|
||||
/>
|
||||
{approvalsOpen ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} />}
|
||||
</Box>
|
||||
<Collapse in={approvalsOpen}>
|
||||
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}>
|
||||
{pendingApprovals.map((app) => (
|
||||
<Box key={app.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', flex: 1 }}>
|
||||
{app.action_type || 'Action'} · {app.status}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10 }}>
|
||||
{app.created_at ? new Date(app.created_at).toLocaleTimeString() : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertBanner;
|
||||
413
frontend/src/components/TeamActivity/CommitteeAuditTable.tsx
Normal file
413
frontend/src/components/TeamActivity/CommitteeAuditTable.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
FileDownload as FileDownloadIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface CommitteeProposal {
|
||||
agent: string;
|
||||
title: string;
|
||||
pillar_id: string;
|
||||
priority: string;
|
||||
valid: boolean;
|
||||
accepted: boolean;
|
||||
reasoning?: string;
|
||||
rejected_reason?: string | null;
|
||||
estimated_time?: number;
|
||||
action_type?: string;
|
||||
}
|
||||
|
||||
interface CommitteePayload {
|
||||
agents_polled: number;
|
||||
total_proposals: number;
|
||||
accepted_count: number;
|
||||
rejected_count: number;
|
||||
proposals: CommitteeProposal[];
|
||||
}
|
||||
|
||||
const PILLAR_LABELS: Record<string, string> = {
|
||||
plan: 'Plan',
|
||||
generate: 'Generate',
|
||||
publish: 'Publish',
|
||||
analyze: 'Analyze',
|
||||
engage: 'Engage',
|
||||
remarket: 'Remarket',
|
||||
};
|
||||
|
||||
type SortKey = 'agent' | 'title' | 'pillar_id' | 'priority' | 'valid' | 'accepted';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
type FilterStatus = 'all' | 'accepted' | 'rejected' | 'invalid';
|
||||
|
||||
const sortProposals = (props: CommitteeProposal[], key: SortKey, dir: SortDir): CommitteeProposal[] => {
|
||||
return [...props].sort((a, b) => {
|
||||
const aVal = String(a[key] ?? '');
|
||||
const bVal = String(b[key] ?? '');
|
||||
const cmp = aVal.localeCompare(bVal);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
};
|
||||
|
||||
const CommitteeAuditTable: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('agent');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all');
|
||||
const [filterAgent, setFilterAgent] = useState<string | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<number | null>(null);
|
||||
|
||||
const meeting = useMemo<CommitteePayload | null>(() => {
|
||||
const last = events.find((e) => e.event_type === 'committee_meeting');
|
||||
if (!last?.payload) return null;
|
||||
return (typeof last.payload === 'string' ? JSON.parse(last.payload) : last.payload) as CommitteePayload;
|
||||
}, [events]);
|
||||
|
||||
const allAgents = useMemo<string[]>(() => {
|
||||
if (!meeting) return [];
|
||||
return Array.from(new Set(meeting.proposals.map((p) => p.agent)));
|
||||
}, [meeting]);
|
||||
|
||||
const filtered = useMemo<CommitteeProposal[]>(() => {
|
||||
if (!meeting) return [];
|
||||
let list = meeting.proposals;
|
||||
|
||||
if (filterStatus === 'accepted') list = list.filter((p) => p.accepted);
|
||||
else if (filterStatus === 'rejected') list = list.filter((p) => !p.accepted);
|
||||
else if (filterStatus === 'invalid') list = list.filter((p) => !p.valid);
|
||||
|
||||
if (filterAgent) list = list.filter((p) => p.agent === filterAgent);
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter((p) => p.title.toLowerCase().includes(q) || p.agent.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return sortProposals(list, sortKey, sortDir);
|
||||
}, [meeting, filterStatus, filterAgent, search, sortKey, sortDir]);
|
||||
|
||||
const handleSort = (key: SortKey) => () => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!meeting) return;
|
||||
const headers = ['Agent', 'Title', 'Pillar', 'Priority', 'Valid', 'Accepted', 'Rejected Reason', 'Reasoning', 'Est. Time', 'Action Type'];
|
||||
const rows = meeting.proposals.map((p) => [
|
||||
p.agent,
|
||||
`"${p.title.replace(/"/g, '""')}"`,
|
||||
p.pillar_id,
|
||||
p.priority,
|
||||
p.valid ? 'Yes' : 'No',
|
||||
p.accepted ? 'Yes' : 'No',
|
||||
p.rejected_reason ? `"${p.rejected_reason.replace(/"/g, '""')}"` : '',
|
||||
p.reasoning ? `"${p.reasoning.replace(/"/g, '""')}"` : '',
|
||||
p.estimated_time ?? '',
|
||||
p.action_type ?? '',
|
||||
].join(','));
|
||||
|
||||
const csv = [headers.join(','), ...rows].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `committee_audit_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!meeting) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
|
||||
Committee Audit — {meeting.total_proposals} proposals
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
onClick={exportCsv}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 13,
|
||||
'& fieldset': { borderColor: 'rgba(255,255,255,0.12)' },
|
||||
'&:hover fieldset': { borderColor: 'rgba(255,255,255,0.25)' },
|
||||
'&.Mui-focused fieldset': { borderColor: 'rgba(102,126,234,0.5)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{(['all', 'accepted', 'rejected', 'invalid'] as FilterStatus[]).map((s) => (
|
||||
<Chip
|
||||
key={s}
|
||||
label={s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
size="small"
|
||||
onClick={() => setFilterStatus(s)}
|
||||
sx={{
|
||||
height: 26,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
bgcolor: filterStatus === s ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
|
||||
color: filterStatus === s ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
|
||||
border: `1px solid ${filterStatus === s ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
|
||||
'&:hover': { bgcolor: filterStatus === s ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{allAgents.map((a) => (
|
||||
<Chip
|
||||
key={a}
|
||||
label={a}
|
||||
size="small"
|
||||
onClick={() => setFilterAgent(filterAgent === a ? null : a)}
|
||||
sx={{
|
||||
height: 26,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
bgcolor: filterAgent === a ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
|
||||
color: filterAgent === a ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
|
||||
border: `1px solid ${filterAgent === a ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
|
||||
'&:hover': { bgcolor: filterAgent === a ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Table */}
|
||||
<TableContainer sx={{ maxHeight: 420, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.15)', borderRadius: 3 } }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{([{ key: 'agent', label: 'Agent' }, { key: 'title', label: 'Title' }, { key: 'pillar_id', label: 'Pillar' }, { key: 'priority', label: 'Priority' }, { key: 'valid', label: 'Valid' }, { key: 'accepted', label: 'Accepted' }] as { key: SortKey; label: string }[]).map(({ key, label }) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
bgcolor: 'rgba(0,0,0,0.3)',
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === key}
|
||||
direction={sortKey === key ? sortDir : 'asc'}
|
||||
onClick={handleSort(key)}
|
||||
sx={{ color: 'inherit !important', '& .MuiTableSortLabel-icon': { color: 'rgba(255,255,255,0.5) !important' } }}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid rgba(255,255,255,0.08)', bgcolor: 'rgba(0,0,0,0.3)', py: 1 }}>
|
||||
Reason
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((p, i) => {
|
||||
const isExpanded = expandedRow === i;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<TableRow
|
||||
hover
|
||||
onClick={() => setExpandedRow(isExpanded ? null : i)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
|
||||
'& td': { borderBottom: '1px solid rgba(255,255,255,0.04)' },
|
||||
opacity: p.accepted ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontWeight: 600, py: 0.75 }}>
|
||||
{p.agent}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, py: 0.75 }}>
|
||||
{p.title}
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={PILLAR_LABELS[p.pillar_id] || p.pillar_id}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
bgcolor: p.valid ? 'rgba(102,126,234,0.2)' : 'rgba(244,67,54,0.2)',
|
||||
color: p.valid ? '#8b9cf7' : '#f44336',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={p.priority}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={p.valid ? 'Yes' : 'No'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
bgcolor: p.valid ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
|
||||
color: p.valid ? '#4caf50' : '#f44336',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={p.accepted ? 'Yes' : 'No'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.accepted ? '#4caf50' : '#9e9e9e',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 11, py: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{p.rejected_reason || (p.accepted ? '—' : 'Duplicate / lower priority')}
|
||||
{isExpanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} sx={{ py: 0, borderBottom: 'none', bgcolor: 'rgba(0,0,0,0.2)' }}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 700, display: 'block', mb: 0.5, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Reasoning
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 1.6, mb: 1.5 }}>
|
||||
{p.reasoning || 'No reasoning provided.'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Est. Time
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
|
||||
{p.estimated_time ? `${p.estimated_time} min` : '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Action Type
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
|
||||
{p.action_type || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} sx={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)', fontSize: 13, py: 4 }}>
|
||||
No proposals match current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommitteeAuditTable;
|
||||
493
frontend/src/components/TeamActivity/CommitteeSummary.tsx
Normal file
493
frontend/src/components/TeamActivity/CommitteeSummary.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Collapse,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
WarningAmber as WarningAmberIcon,
|
||||
Error as ErrorIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface CommitteeProposal {
|
||||
agent: string;
|
||||
title: string;
|
||||
pillar_id: string;
|
||||
priority: string;
|
||||
valid: boolean;
|
||||
accepted: boolean;
|
||||
reasoning?: string;
|
||||
rejected_reason?: string | null;
|
||||
estimated_time?: number;
|
||||
action_type?: string;
|
||||
}
|
||||
|
||||
interface CommitteePayload {
|
||||
agents_polled: number;
|
||||
total_proposals: number;
|
||||
accepted_count: number;
|
||||
rejected_count: number;
|
||||
proposals: CommitteeProposal[];
|
||||
}
|
||||
|
||||
const PILLAR_ORDER = ['plan', 'generate', 'publish', 'analyze', 'engage', 'remarket'];
|
||||
const PILLAR_INFO: Record<string, { label: string; short: string; desc: string }> = {
|
||||
plan: { label: 'Plan', short: 'Plan', desc: 'Strategy & planning' },
|
||||
generate: { label: 'Generate', short: 'Create', desc: 'Content creation' },
|
||||
publish: { label: 'Publish', short: 'Pub.', desc: 'Publishing & scheduling' },
|
||||
analyze: { label: 'Analyze', short: 'Audit', desc: 'Performance review' },
|
||||
engage: { label: 'Engage', short: 'Share', desc: 'Social engagement' },
|
||||
remarket: { label: 'Remarket', short: 'ReMkt', desc: 'Repurpose & promote' },
|
||||
};
|
||||
|
||||
// ─── Status Banner ──────────────────────────────────
|
||||
const statusMeta = (accepted: number, total: number) => {
|
||||
const pct = total > 0 ? accepted / total : 0;
|
||||
if (pct >= 0.8) return { color: '#4caf50', bg: 'rgba(76,175,80,0.12)', icon: <CheckCircleIcon sx={{ fontSize: 20, color: '#4caf50' }} />, text: 'All systems good' };
|
||||
if (pct >= 0.5) return { color: '#ff9800', bg: 'rgba(255,152,0,0.12)', icon: <WarningAmberIcon sx={{ fontSize: 20, color: '#ff9800' }} />, text: 'Needs review' };
|
||||
return { color: '#f44336', bg: 'rgba(244,67,54,0.12)', icon: <ErrorIcon sx={{ fontSize: 20, color: '#f44336' }} />, text: 'Attention needed' };
|
||||
};
|
||||
|
||||
const StatusBanner: React.FC<{ accepted: number; total: number; agents: number }> = ({ accepted, total, agents }) => {
|
||||
const meta = statusMeta(accepted, total);
|
||||
const pct = total > 0 ? Math.round(accepted / total * 100) : 0;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: meta.bg,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{meta.icon}
|
||||
<Typography variant="body2" sx={{ color: meta.color, fontWeight: 600, flex: 1 }}>
|
||||
{meta.text} — <Box component="span" sx={{ fontWeight: 400 }}>{accepted} of {total} proposals adopted from {agents} areas</Box>
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: meta.color, fontWeight: 800, fontSize: '1.1rem' }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Adoption Bar ───────────────────────────────────
|
||||
const AdoptionBar: React.FC<{ accepted: number; total: number }> = ({ accepted, total }) => {
|
||||
const pct = total > 0 ? accepted / total * 100 : 0;
|
||||
const color = pct >= 80 ? '#4caf50' : pct >= 50 ? '#ff9800' : '#f44336';
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Adoption
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>
|
||||
Adopted <Box component="span" sx={{ color }}>{accepted}</Box> of {total} proposals
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: `linear-gradient(90deg, rgba(102,126,234,0.8), ${color})`,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Coverage Flow ──────────────────────────────────
|
||||
const coverageHealth = (count: number): { color: string; label: string; dot: string } => {
|
||||
if (count >= 3) return { color: '#4caf50', label: 'covered', dot: '●' };
|
||||
if (count >= 1) return { color: '#ff9800', label: 'light', dot: '◕' };
|
||||
return { color: '#f44336', label: 'missing', dot: '○' };
|
||||
};
|
||||
|
||||
const CoverageFlow: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
|
||||
const counts = PILLAR_ORDER.map((p) => ({
|
||||
...PILLAR_INFO[p],
|
||||
key: p,
|
||||
count: proposals.filter((pr) => pr.pillar_id === p).length,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Today's Coverage
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'nowrap', overflow: 'auto', pb: 0.5 }}>
|
||||
{counts.map((p, i) => {
|
||||
const health = coverageHealth(p.count);
|
||||
return (
|
||||
<React.Fragment key={p.key}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
minWidth: 56,
|
||||
py: 1,
|
||||
px: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${health.color}22`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 700, fontSize: 11, lineHeight: 1.2 }}>
|
||||
{p.short}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 800, fontSize: '1rem', lineHeight: 1.2 }}>
|
||||
{p.count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: health.color, fontSize: 9, fontWeight: 600 }}>
|
||||
{health.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{i < counts.length - 1 && (
|
||||
<ArrowForwardIcon sx={{ mx: 0.25, color: 'rgba(255,255,255,0.15)', fontSize: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Rejected List (redesigned) ─────────────────────
|
||||
const plainReason = (p: CommitteeProposal): string => {
|
||||
if (p.rejected_reason) return p.rejected_reason;
|
||||
if (!p.valid) return `"${p.pillar_id}" isn't a valid workflow phase — this is a system configuration issue.`;
|
||||
return 'This suggestion was similar to an existing task or had lower priority.';
|
||||
};
|
||||
|
||||
const actionForProposal = (p: CommitteeProposal): { label: string; icon?: React.ReactNode } | null => {
|
||||
const title = p.title.toLowerCase();
|
||||
if (title.includes('twitter') || title.includes('tweet')) {
|
||||
return { label: 'Connect Twitter' };
|
||||
}
|
||||
if (title.includes('linkedin')) {
|
||||
return { label: 'Connect LinkedIn' };
|
||||
}
|
||||
if (title.includes('facebook') || title.includes('instagram')) {
|
||||
return { label: 'Connect Social' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const RejectedList: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
|
||||
const rejected = proposals.filter((p) => !p.accepted);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (rejected.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<Box
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={rejected.length}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
minWidth: 20,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
bgcolor: 'rgba(244,67,54,0.2)',
|
||||
color: '#f44336',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>
|
||||
suggestion{rejected.length > 1 ? 's' : ''} not included
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', mr: 0.5 }}>
|
||||
{open ? 'hide' : 'view'}
|
||||
</Typography>
|
||||
{open ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={open}>
|
||||
<Box sx={{ pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{rejected.map((p, i) => {
|
||||
const action = actionForProposal(p);
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<InfoIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.25)', mt: 0.25, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, mb: 0.25 }}>
|
||||
“{p.title}”
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', lineHeight: 1.4 }}>
|
||||
{plainReason(p)}
|
||||
</Typography>
|
||||
{action && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
height: 24,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
color: '#8b9cf7',
|
||||
borderColor: 'rgba(102,126,234,0.3)',
|
||||
'&:hover': { borderColor: 'rgba(102,126,234,0.6)', bgcolor: 'rgba(102,126,234,0.1)' },
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Chip
|
||||
label={p.agent}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
bgcolor: 'rgba(255,255,255,0.06)',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Agent Row (details section) ────────────────────
|
||||
type AgentStatus = 'all_accepted' | 'partial' | 'all_rejected';
|
||||
interface AgentSummary {
|
||||
name: string;
|
||||
total: number;
|
||||
accepted: number;
|
||||
status: AgentStatus;
|
||||
proposals: CommitteeProposal[];
|
||||
}
|
||||
|
||||
const agentStatusIcon = (s: AgentStatus) => {
|
||||
if (s === 'all_accepted') return <CheckCircleIcon sx={{ fontSize: 18, color: '#4caf50' }} />;
|
||||
if (s === 'partial') return <WarningAmberIcon sx={{ fontSize: 18, color: '#ff9800' }} />;
|
||||
return <ErrorIcon sx={{ fontSize: 18, color: '#f44336' }} />;
|
||||
};
|
||||
|
||||
const agentStatusColor = (s: AgentStatus): 'success' | 'warning' | 'error' => {
|
||||
if (s === 'all_accepted') return 'success';
|
||||
if (s === 'partial') return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const AgentRow: React.FC<{ agent: AgentSummary; expanded: boolean; onToggle: () => void }> = ({ agent, expanded, onToggle }) => {
|
||||
const pct = agent.total > 0 ? agent.accepted / agent.total : 0;
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.6, px: 1.5, borderRadius: 2,
|
||||
cursor: 'pointer', transition: 'background 0.2s',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
|
||||
}}
|
||||
>
|
||||
{agentStatusIcon(agent.status)}
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 140, color: 'rgba(255,255,255,0.9)' }}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, maxWidth: 140 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct * 100}
|
||||
color={agentStatusColor(agent.status)}
|
||||
sx={{ height: 5, borderRadius: 2.5, bgcolor: 'rgba(255,255,255,0.08)' }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', minWidth: 80, textAlign: 'right' }}>
|
||||
{agent.accepted}/{agent.total}
|
||||
</Typography>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} />}
|
||||
</Box>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ ml: 5, mr: 1.5, mb: 0.5 }}>
|
||||
{agent.proposals.map((p, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35, px: 1, borderRadius: 1, bgcolor: p.accepted ? 'rgba(76,175,80,0.06)' : 'transparent' }}>
|
||||
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.8)' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip label={PILLAR_INFO[p.pillar_id]?.label || p.pillar_id} size="small" sx={{
|
||||
height: 20, fontSize: 10, fontWeight: 600,
|
||||
bgcolor: p.valid ? 'rgba(102,126,234,0.2)' : 'rgba(244,67,54,0.2)',
|
||||
color: p.valid ? '#8b9cf7' : '#f44336',
|
||||
border: `1px solid ${p.valid ? 'rgba(102,126,234,0.3)' : 'rgba(244,67,54,0.3)'}`,
|
||||
}} />
|
||||
<Chip label={p.priority} size="small" sx={{
|
||||
height: 20, fontSize: 10, fontWeight: 600, textTransform: 'capitalize',
|
||||
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
|
||||
}} />
|
||||
<Chip label={p.accepted ? 'Accepted' : 'Skipped'} size="small" sx={{
|
||||
height: 20, fontSize: 10, fontWeight: 600,
|
||||
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
|
||||
color: p.accepted ? '#4caf50' : '#9e9e9e',
|
||||
}} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main Component ─────────────────────────────────
|
||||
const CommitteeSummary: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
|
||||
const meeting = useMemo<CommitteePayload | null>(() => {
|
||||
const last = events.find((e) => e.event_type === 'committee_meeting');
|
||||
if (!last?.payload) return null;
|
||||
return (typeof last.payload === 'string' ? JSON.parse(last.payload) : last.payload) as CommitteePayload;
|
||||
}, [events]);
|
||||
|
||||
const agents = useMemo<AgentSummary[]>(() => {
|
||||
if (!meeting) return [];
|
||||
const map = new Map<string, CommitteeProposal[]>();
|
||||
for (const p of meeting.proposals) {
|
||||
if (!map.has(p.agent)) map.set(p.agent, []);
|
||||
map.get(p.agent)!.push(p);
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, proposals]) => {
|
||||
const accepted = proposals.filter((p) => p.accepted).length;
|
||||
const total = proposals.length;
|
||||
let status: AgentStatus = 'all_accepted';
|
||||
if (accepted === 0) status = 'all_rejected';
|
||||
else if (accepted < total) status = 'partial';
|
||||
return { name, total, accepted, status, proposals };
|
||||
});
|
||||
}, [meeting]);
|
||||
|
||||
if (!meeting) return null;
|
||||
|
||||
const summaryLine = `ALwrity reviewed ${meeting.total_proposals} suggestions across ${meeting.agents_polled} areas of your content workflow and built today's plan from ${meeting.accepted_count} of them.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.06) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.16)',
|
||||
borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Header + summary line */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem', mb: 0.25 }}>
|
||||
Daily Committee Brief
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block', maxWidth: 480 }}>
|
||||
{summaryLine}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
flexShrink: 0,
|
||||
'&:hover': { color: 'rgba(255,255,255,0.8)', bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}}
|
||||
>
|
||||
{showDetails ? 'Hide details' : 'Show details'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Status banner */}
|
||||
<StatusBanner accepted={meeting.accepted_count} total={meeting.total_proposals} agents={meeting.agents_polled} />
|
||||
|
||||
{/* Adoption bar */}
|
||||
<AdoptionBar accepted={meeting.accepted_count} total={meeting.total_proposals} />
|
||||
|
||||
{/* Coverage flow */}
|
||||
<CoverageFlow proposals={meeting.proposals} />
|
||||
|
||||
{/* Rejected proposals */}
|
||||
<RejectedList proposals={meeting.proposals} />
|
||||
|
||||
{/* Details section: agent-level breakdown */}
|
||||
<Collapse in={showDetails}>
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 0.5, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Agent Breakdown
|
||||
</Typography>
|
||||
{agents.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
expanded={expandedAgent === agent.name}
|
||||
onToggle={() => setExpandedAgent(expandedAgent === agent.name ? null : agent.name)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommitteeSummary;
|
||||
400
frontend/src/components/TeamActivity/QualityAuditPanel.tsx
Normal file
400
frontend/src/components/TeamActivity/QualityAuditPanel.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Collapse,
|
||||
Button,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
WarningAmber as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Gavel as GavelIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface ReasoningIssue { title: string; reasoning: string; score: number }
|
||||
interface PriorityIssue { title: string; pillar: string; priority: string; note: string }
|
||||
interface PillarIssue { title: string; proposed_pillar: string; expected_pillar: string; note: string }
|
||||
interface RejectedDetail { title: string; reason: string }
|
||||
interface AgentIssue {
|
||||
type: string; severity: string; count: number; summary: string;
|
||||
details?: ReasoningIssue[] | PriorityIssue[] | PillarIssue[] | RejectedDetail[];
|
||||
action_label?: string; action_url?: string | null;
|
||||
}
|
||||
interface AgentCritique {
|
||||
agent: string; label: string; short: string;
|
||||
score: number; health: string;
|
||||
total_proposals: number; accepted: number; rejected: number;
|
||||
acceptance_rate: number;
|
||||
issues: AgentIssue[];
|
||||
summary: string;
|
||||
}
|
||||
interface CoverageGap { pillar_id: string; severity: string; summary: string; action_label?: string; action_url?: string | null }
|
||||
interface Overlap { title: string; pillar: string; agents: string[]; count: number; severity: string; summary: string; action_label?: string; action_url?: string | null }
|
||||
interface AuditAlert { type: string; severity: string; agent?: string; label?: string; title: string; message: string; cta_path?: string | null }
|
||||
|
||||
interface AuditReport {
|
||||
health_score: number; verdict: string;
|
||||
agent_critiques: AgentCritique[];
|
||||
coverage_gaps: CoverageGap[];
|
||||
overstuffed_pillars?: CoverageGap[];
|
||||
overlaps: Overlap[];
|
||||
alerts: AuditAlert[];
|
||||
audit_timestamp: string;
|
||||
}
|
||||
|
||||
const healthColor = (score: number) => score >= 80 ? '#4caf50' : score >= 50 ? '#ff9800' : '#f44336';
|
||||
const healthLabel = (score: number) => score >= 80 ? 'Healthy' : score >= 50 ? 'Needs review' : 'Failing';
|
||||
|
||||
// ── Health Ring ─────────────────────────────────────
|
||||
const HealthRing: React.FC<{ score: number }> = ({ score }) => {
|
||||
const color = healthColor(score);
|
||||
const r = 36, circ = 2 * Math.PI * r;
|
||||
const offset = circ - (score / 100) * circ;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
|
||||
<Box sx={{ position: 'relative', width: 80, height: 80, flexShrink: 0 }}>
|
||||
<svg width={80} height={80} viewBox="0 0 80 80">
|
||||
<circle cx={40} cy={40} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={6} />
|
||||
<circle cx={40} cy={40} r={r} fill="none" stroke={color} strokeWidth={6}
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
transform="rotate(-90 40 40)" strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<Typography sx={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 800, fontSize: '1.3rem', color: color, lineHeight: 1,
|
||||
}}>
|
||||
{score}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)', fontSize: '0.9rem' }}>
|
||||
Committee Health — {healthLabel(score)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', lineHeight: 1.4, mt: 0.25 }}>
|
||||
{score >= 80 ? 'All agents submitting quality proposals.' : score >= 50 ? 'Some agents need attention.' : 'Significant issues detected.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Agent Critique Card ─────────────────────────────
|
||||
const issueIcon = (sev: string) => {
|
||||
if (sev === 'error') return <ErrorIcon sx={{ fontSize: 14, color: '#f44336' }} />;
|
||||
if (sev === 'warning') return <WarningIcon sx={{ fontSize: 14, color: '#ff9800' }} />;
|
||||
return <InfoIcon sx={{ fontSize: 14, color: '#2196f3' }} />;
|
||||
};
|
||||
|
||||
const issueBg = (sev: string) => sev === 'error' ? 'rgba(244,67,54,0.08)' : sev === 'warning' ? 'rgba(255,152,0,0.08)' : 'rgba(33,150,243,0.08)';
|
||||
|
||||
const AgentCritiqueCard: React.FC<{ critique: AgentCritique; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ critique, onNavigate }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const color = healthColor(critique.score);
|
||||
const hasIssues = critique.issues.length > 0;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: 2,
|
||||
bgcolor: critique.health === 'failing' ? 'rgba(244,67,54,0.04)' : critique.health === 'warning' ? 'rgba(255,152,0,0.04)' : 'rgba(76,175,80,0.04)',
|
||||
border: `1px solid ${color}22`,
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.9)', fontSize: 13, flex: 1 }}>
|
||||
{critique.label}
|
||||
</Typography>
|
||||
<Chip label={`${critique.accepted}/${critique.total_proposals}`} size="small" sx={{
|
||||
height: 18, fontSize: 9, fontWeight: 700,
|
||||
bgcolor: critique.acceptance_rate > 0.5 ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
|
||||
color: critique.acceptance_rate > 0.5 ? '#4caf50' : '#f44336',
|
||||
}} />
|
||||
<Chip label={`${critique.score}/100`} size="small" sx={{
|
||||
height: 18, fontSize: 9, fontWeight: 700, bgcolor: `${color}22`, color,
|
||||
}} />
|
||||
</Box>
|
||||
|
||||
{/* Mini bar */}
|
||||
<LinearProgress variant="determinate" value={critique.score} sx={{
|
||||
height: 3, borderRadius: 1.5, mb: 0.75,
|
||||
bgcolor: 'rgba(255,255,255,0.06)',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: color },
|
||||
}} />
|
||||
|
||||
{/* Summary */}
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontSize: 10, display: 'block' }}>
|
||||
{critique.summary}
|
||||
</Typography>
|
||||
|
||||
{/* Issues */}
|
||||
{hasIssues && (
|
||||
<>
|
||||
<Box onClick={() => setExpanded(!expanded)} sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.75, cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.8 }, userSelect: 'none',
|
||||
}}>
|
||||
<GavelIcon sx={{ fontSize: 12, color: 'rgba(255,255,255,0.3)' }} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 600, fontSize: 10 }}>
|
||||
{critique.issues.length} issue{critique.issues.length > 1 ? 's' : ''}
|
||||
</Typography>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{critique.issues.map((issue, i) => (
|
||||
<Box key={i} sx={{ p: 0.75, borderRadius: 1.5, bgcolor: issueBg(issue.severity), border: `1px solid ${issue.severity === 'error' ? 'rgba(244,67,54,0.15)' : issue.severity === 'warning' ? 'rgba(255,152,0,0.15)' : 'rgba(33,150,243,0.15)'}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
{issueIcon(issue.severity)}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 600, fontSize: 10, display: 'block' }}>
|
||||
{issue.summary}
|
||||
</Typography>
|
||||
{issue.details && (issue.details as any[]).slice(0, 2).map((d: any, j) => (
|
||||
<Box key={j} sx={{ mt: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 9, display: 'block' }}>
|
||||
• {d.title}: {d.reasoning || d.reason || d.note || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{issue.details && (issue.details as any[]).length > 2 && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', fontSize: 9 }}>
|
||||
+{(issue.details as any[]).length - 2} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{issue.action_url && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(issue.action_url!); }}
|
||||
sx={{ textTransform: 'none', fontSize: 9, color: '#4f46e5', py: 0, minWidth: 0, pl: 0.5 }}
|
||||
endIcon={<ArrowForwardIcon sx={{ fontSize: 10 }} />}
|
||||
>
|
||||
{issue.action_label || 'View'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Coverage Gap Row ─────────────────────────────────
|
||||
const GapRow: React.FC<{ gap: CoverageGap; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ gap, onNavigate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: 'rgba(255,152,0,0.06)' }}>
|
||||
<WarningIcon sx={{ fontSize: 12, color: '#ff9800' }} />
|
||||
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>{gap.summary}</Typography>
|
||||
<Chip label={`pillar: ${gap.pillar_id}`} size="small" sx={{ height: 16, fontSize: 8, fontWeight: 600, bgcolor: 'rgba(255,152,0,0.1)', color: '#ff9800' }} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const url = gap.action_url || '/content-planning';
|
||||
onNavigate(url, { pillarId: gap.pillar_id, source: 'quality_audit_gap' });
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: 9, py: 0.125, px: 0.75, minWidth: 0,
|
||||
borderColor: 'rgba(255,152,0,0.3)', color: '#ff9800',
|
||||
'&:hover': { borderColor: '#ff9800', bgcolor: 'rgba(255,152,0,0.08)' },
|
||||
}}
|
||||
>
|
||||
{gap.action_label || 'Fill gap'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ── Overlap Row ──────────────────────────────────────
|
||||
const OverlapRow: React.FC<{ overlap: Overlap; onNavigate: (url: string, state?: Record<string, unknown>) => void }> = ({ overlap, onNavigate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: 'rgba(33,150,243,0.06)' }}>
|
||||
<InfoIcon sx={{ fontSize: 12, color: '#2196f3' }} />
|
||||
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>{overlap.summary}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const url = overlap.action_url || '/content-planning';
|
||||
onNavigate(url, { pillar: overlap.pillar, overlapAgents: overlap.agents, source: 'quality_audit_overlap' });
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: 9, py: 0.125, px: 0.75, minWidth: 0,
|
||||
borderColor: 'rgba(33,150,243,0.3)', color: '#2196f3',
|
||||
'&:hover': { borderColor: '#2196f3', bgcolor: 'rgba(33,150,243,0.08)' },
|
||||
}}
|
||||
>
|
||||
{overlap.action_label || 'Resolve'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ── Alert Row ────────────────────────────────────────
|
||||
const AlertRow: React.FC<{ alert: AuditAlert; onNavigate: (url: string) => void }> = ({ alert, onNavigate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.4, px: 0.5, borderRadius: 1, bgcolor: alert.severity === 'error' ? 'rgba(244,67,54,0.06)' : alert.severity === 'warning' ? 'rgba(255,152,0,0.06)' : 'rgba(33,150,243,0.06)' }}>
|
||||
{alert.severity === 'error' ? <ErrorIcon sx={{ fontSize: 12, color: '#f44336' }} /> : alert.severity === 'warning' ? <WarningIcon sx={{ fontSize: 12, color: '#ff9800' }} /> : <InfoIcon sx={{ fontSize: 12, color: '#2196f3' }} />}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 600, fontSize: 10, display: 'block' }}>
|
||||
{alert.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', fontSize: 9, display: 'block' }}>
|
||||
{alert.message}
|
||||
</Typography>
|
||||
</Box>
|
||||
{alert.cta_path && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => onNavigate(alert.cta_path!)}
|
||||
endIcon={<OpenInNewIcon sx={{ fontSize: 10 }} />}
|
||||
sx={{ textTransform: 'none', fontSize: 9, color: '#4f46e5', py: 0, minWidth: 0 }}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ── Main Component ───────────────────────────────────
|
||||
const QualityAuditPanel: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const report = useMemo<AuditReport | null>(() => {
|
||||
const evt = events.find((e) => e.event_type === 'quality_audit');
|
||||
if (!evt?.payload) return null;
|
||||
return (typeof evt.payload === 'string' ? JSON.parse(evt.payload) : evt.payload) as AuditReport;
|
||||
}, [events]);
|
||||
|
||||
const [critiquesOpen, setCritiquesOpen] = useState(false);
|
||||
const [gapsOpen, setGapsOpen] = useState(false);
|
||||
const [overlapsOpen, setOverlapsOpen] = useState(false);
|
||||
|
||||
const handleNavigate = useCallback((url: string, state?: Record<string, unknown>) => {
|
||||
if (url.startsWith('/')) {
|
||||
navigate(url, { state: state || {} });
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
if (!report) return null;
|
||||
|
||||
const hasAlerts = report.alerts.length > 0;
|
||||
const hasGaps = report.coverage_gaps.length > 0;
|
||||
const hasOverlaps = report.overlaps.length > 0;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
backdropFilter: 'blur(22px)', WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)', borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25)', p: 2.5, mb: 2,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<GavelIcon sx={{ fontSize: 18, color: healthColor(report.health_score) }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
|
||||
Committee Watchdog
|
||||
</Typography>
|
||||
{hasAlerts && (
|
||||
<Chip label={`${report.alerts.length} alert${report.alerts.length > 1 ? 's' : ''}`} size="small" sx={{
|
||||
ml: 'auto', height: 20, fontSize: 10, fontWeight: 700,
|
||||
bgcolor: 'rgba(244,67,54,0.15)', color: '#f44336',
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Health gauge + verdict */}
|
||||
<HealthRing score={report.health_score} />
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', mb: 1.5, lineHeight: 1.4, fontStyle: 'italic' }}>
|
||||
{report.verdict}
|
||||
</Typography>
|
||||
|
||||
{/* Alerts */}
|
||||
{hasAlerts && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(244,67,54,0.8)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, display: 'block', mb: 0.5 }}>
|
||||
Alerts ({report.alerts.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{report.alerts.map((a, i) => <AlertRow key={i} alert={a} onNavigate={handleNavigate} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Agent critiques */}
|
||||
{report.agent_critiques.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box onClick={() => setCritiquesOpen(!critiquesOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
|
||||
Agent Critiques ({report.agent_critiques.length})
|
||||
</Typography>
|
||||
{critiquesOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
<Collapse in={critiquesOpen}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{report.agent_critiques.map((c, i) => (
|
||||
<AgentCritiqueCard key={i} critique={c} onNavigate={handleNavigate} />
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Coverage gaps */}
|
||||
{hasGaps && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box onClick={() => setGapsOpen(!gapsOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,152,0,0.7)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
|
||||
Coverage Gaps ({report.coverage_gaps.length})
|
||||
</Typography>
|
||||
{gapsOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
<Collapse in={gapsOpen}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{report.coverage_gaps.map((g, i) => <GapRow key={i} gap={g} onNavigate={handleNavigate} />)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Overlaps */}
|
||||
{hasOverlaps && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box onClick={() => setOverlapsOpen(!overlapsOpen)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5, cursor: 'pointer', userSelect: 'none', '&:hover': { opacity: 0.8 } }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(33,150,243,0.7)', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10, flex: 1 }}>
|
||||
Overlaps ({report.overlaps.length})
|
||||
</Typography>
|
||||
{overlapsOpen ? <ExpandLessIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />}
|
||||
</Box>
|
||||
<Collapse in={overlapsOpen}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{report.overlaps.map((o, i) => <OverlapRow key={i} overlap={o} onNavigate={handleNavigate} />)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Auto-collapse hint */}
|
||||
{!critiquesOpen && !gapsOpen && !overlapsOpen && !hasAlerts && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.2)', display: 'block', textAlign: 'center', fontSize: 9, mt: 0.5 }}>
|
||||
Tap sections above to expand details
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityAuditPanel;
|
||||
215
frontend/src/components/TeamActivity/TrendSignalsPanel.tsx
Normal file
215
frontend/src/components/TeamActivity/TrendSignalsPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendIcon,
|
||||
Whatshot as HotIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface TrendOpportunity {
|
||||
trend_id: string;
|
||||
topic: string;
|
||||
headline: string;
|
||||
source: string;
|
||||
urgency: string;
|
||||
impact_score: number;
|
||||
current_coverage: number;
|
||||
recommendation: string;
|
||||
suggested_angle: string;
|
||||
detected_at: string;
|
||||
}
|
||||
|
||||
interface TrendSignalPayload {
|
||||
opportunities: TrendOpportunity[];
|
||||
total_detected: number;
|
||||
scan_timestamp: string;
|
||||
}
|
||||
|
||||
const urgencyColor = (u: string) => {
|
||||
if (u === 'critical') return '#f44336';
|
||||
if (u === 'high') return '#ff9800';
|
||||
return '#4caf50';
|
||||
};
|
||||
|
||||
const recommendationLabel = (r: string) => {
|
||||
if (r === 'create_content' || r === 'create') return 'Create Content';
|
||||
return r.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
const TrendSignalsPanel: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const signals = useMemo<TrendSignalPayload | null>(() => {
|
||||
const evt = events.find((e) => e.event_type === 'trend_signals');
|
||||
if (!evt?.payload) return null;
|
||||
return (typeof evt.payload === 'string' ? JSON.parse(evt.payload) : evt.payload) as TrendSignalPayload;
|
||||
}, [events]);
|
||||
|
||||
if (!signals?.opportunities?.length) return null;
|
||||
|
||||
const handleCreateContent = (opp: TrendOpportunity) => {
|
||||
navigate('/blog-writer', {
|
||||
state: {
|
||||
trendTopic: opp.topic,
|
||||
trendHeadline: opp.headline,
|
||||
trendAngle: opp.suggested_angle,
|
||||
trendUrgency: opp.urgency,
|
||||
trendImpact: opp.impact_score,
|
||||
source: 'trend_signals',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<TrendIcon sx={{ fontSize: 18, color: '#ff9800' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
|
||||
Trend Signals
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${signals.total_detected} detected`}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', height: 20, fontSize: 10, fontWeight: 600, bgcolor: 'rgba(255,152,0,0.15)', color: '#ff9800' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Opportunities */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{signals.opportunities.map((opp, i) => (
|
||||
<Box
|
||||
key={opp.trend_id || i}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
|
||||
}}
|
||||
>
|
||||
{/* Headline + urgency */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.75 }}>
|
||||
<HotIcon sx={{ fontSize: 14, color: urgencyColor(opp.urgency), mt: 0.25, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, lineHeight: 1.3 }}>
|
||||
{opp.headline || opp.topic}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={opp.urgency}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
bgcolor: `${urgencyColor(opp.urgency)}22`,
|
||||
color: urgencyColor(opp.urgency),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Angle */}
|
||||
{opp.suggested_angle && (
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', mb: 0.75, lineHeight: 1.4, pl: 3 }}>
|
||||
{opp.suggested_angle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Metrics row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pl: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Impact
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={opp.impact_score * 100}
|
||||
sx={{
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: opp.impact_score > 0.7 ? '#ff9800' : '#8b9cf7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 9, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Coverage
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={opp.current_coverage * 100}
|
||||
sx={{
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'rgba(255,255,255,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: opp.current_coverage > 0.7 ? '#4caf50' : opp.current_coverage > 0.3 ? '#ff9800' : '#8b9cf7',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Chip
|
||||
label={recommendationLabel(opp.recommendation)}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 9, fontWeight: 600, bgcolor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action button */}
|
||||
{(opp.recommendation === 'create_content' || opp.recommendation === 'create') && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.75, pl: 3 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<EditNoteIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => handleCreateContent(opp)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: 10,
|
||||
py: 0.25,
|
||||
px: 1,
|
||||
borderColor: 'rgba(255,152,0,0.3)',
|
||||
color: '#ff9800',
|
||||
'&:hover': {
|
||||
borderColor: '#ff9800',
|
||||
bgcolor: 'rgba(255,152,0,0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create content from this trend
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendSignalsPanel;
|
||||
@@ -1,20 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { WIX_CLIENT_ID } from '../../config/wixConfig';
|
||||
import { storeEncrypted } from '../../utils/wixTokenStorage';
|
||||
|
||||
const FALLBACK_ORIGIN = 'http://localhost:3000';
|
||||
|
||||
const WixCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return;
|
||||
ranRef.current = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const wixClient = createClient({ auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
const { code, state, error, errorDescription } = wixClient.auth.parseFromUrl();
|
||||
if (error) {
|
||||
setError(`${error}: ${errorDescription || ''}`);
|
||||
const wixClient = createClient({ auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
|
||||
const { code, state, error: wixError, errorDescription } = wixClient.auth.parseFromUrl();
|
||||
if (wixError) {
|
||||
setError(`${wixError}: ${errorDescription || ''}`);
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
setError('Missing authorization code in URL');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,29 +47,54 @@ const WixCallbackPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WixCallbackPage] oauthData keys:', Object.keys(oauthData || {}));
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
let expiresIn: number | null = null;
|
||||
let siteInfo: any = null;
|
||||
|
||||
// === PRIMARY PATH: Client-side exchange (Wix SDK has internal code_verifier) ===
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/auth/callback', { code, state });
|
||||
if (response.data.success) {
|
||||
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);
|
||||
console.log('[WixCallbackPage] Attempting client-side token exchange...');
|
||||
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
|
||||
wixClient.auth.setTokens(tokens);
|
||||
accessToken = (tokens as any)?.accessToken?.value || (tokens as any)?.access_token || null;
|
||||
refreshToken = (tokens as any)?.refreshToken?.value || (tokens as any)?.refresh_token || null;
|
||||
expiresIn = (tokens as any)?.accessToken?.expiresAt
|
||||
? Math.floor(((tokens as any).accessToken.expiresAt - Date.now()) / 1000)
|
||||
: (tokens as any)?.expires_in || null;
|
||||
console.log('[WixCallbackPage] Client-side exchange OK. accessToken present:', !!accessToken);
|
||||
} catch (clientError: any) {
|
||||
console.error('[WixCallbackPage] Client-side exchange failed:', clientError);
|
||||
setError(`Client-side token exchange failed: ${clientError?.message || clientError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// === SECONDARY PATH: Send token to backend for storage ===
|
||||
if (accessToken) {
|
||||
try {
|
||||
console.log('[WixCallbackPage] Sending token to backend for storage...');
|
||||
const response = await apiClient.post('/api/wix/auth/callback', {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: expiresIn,
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
if (response.data.success) {
|
||||
siteInfo = response.data.site_info || null;
|
||||
console.log('[WixCallbackPage] Backend stored token successfully');
|
||||
} else {
|
||||
console.warn('[WixCallbackPage] Backend store returned:', response.data.message);
|
||||
}
|
||||
} catch (backendError: any) {
|
||||
console.warn('[WixCallbackPage] Backend store failed (non-fatal):', backendError);
|
||||
}
|
||||
}
|
||||
|
||||
// Store in current origin's storage
|
||||
try {
|
||||
if (accessToken) localStorage.setItem('wix_access_token', accessToken);
|
||||
if (accessToken) await storeEncrypted('wix_access_token', accessToken);
|
||||
} catch {}
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
@@ -69,9 +104,7 @@ const WixCallbackPage: React.FC = () => {
|
||||
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.
|
||||
// Persist across cross-origin redirect via window.name
|
||||
try {
|
||||
const payload = { access_token: accessToken, site_info: siteInfo };
|
||||
(window as any).name = `WIX_RESULT::${btoa(JSON.stringify(payload))}`;
|
||||
@@ -90,9 +123,7 @@ const WixCallbackPage: React.FC = () => {
|
||||
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.
|
||||
// Build redirect URL
|
||||
let redirectUrl = oauthData?.redirect_to || sessionStorage.getItem('wix_oauth_redirect');
|
||||
if (redirectUrl) {
|
||||
sessionStorage.removeItem('wix_oauth_redirect');
|
||||
@@ -104,7 +135,6 @@ const WixCallbackPage: React.FC = () => {
|
||||
redirectUrl = `${redirectUrl}?wix_connected=true`;
|
||||
}
|
||||
} else {
|
||||
// Fallback: construct localhost URL
|
||||
redirectUrl = `${FALLBACK_ORIGIN}/blog-writer?wix_connected=true#publish`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { apiClient } from '../../api/client';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
import { categories as blogCategoriesModule, tags as blogTagsModule } from '@wix/blog';
|
||||
import { WIX_CLIENT_ID, getWixRedirectOrigin } from '../../config/wixConfig';
|
||||
|
||||
interface WixConnectionStatus {
|
||||
connected: boolean;
|
||||
@@ -108,11 +109,10 @@ This integration opens up new possibilities for content creators who want to lev
|
||||
setLoading(true);
|
||||
try {
|
||||
const wixClient = createClient({
|
||||
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
|
||||
auth: OAuthStrategy({ clientId: WIX_CLIENT_ID })
|
||||
});
|
||||
|
||||
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
|
||||
const redirectOrigin = getWixRedirectOrigin();
|
||||
const redirectUri = `${redirectOrigin}/wix/callback`;
|
||||
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
|
||||
// Use sessionStorage to ensure data is scoped to this tab/session
|
||||
@@ -131,7 +131,7 @@ This integration opens up new possibilities for content creators who want to lev
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) throw new Error('Missing Wix tokens');
|
||||
const tokens = JSON.parse(tokensRaw);
|
||||
const wixClient = createClient({ modules: { categories: blogCategoriesModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
const wixClient = createClient({ modules: { categories: blogCategoriesModule }, auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
|
||||
wixClient.auth.setTokens(tokens);
|
||||
const result = await wixClient.categories.queryCategories().find();
|
||||
const cats = (result.items || []).map((c: any) => ({ id: c.id, name: c.name || '', description: c.description || '' }));
|
||||
@@ -147,7 +147,7 @@ This integration opens up new possibilities for content creators who want to lev
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) throw new Error('Missing Wix tokens');
|
||||
const tokens = JSON.parse(tokensRaw);
|
||||
const wixClient = createClient({ modules: { tags: blogTagsModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
const wixClient = createClient({ modules: { tags: blogTagsModule }, auth: OAuthStrategy({ clientId: WIX_CLIENT_ID }) });
|
||||
wixClient.auth.setTokens(tokens);
|
||||
const result = await wixClient.tags.queryTags().find();
|
||||
const t = (result.items || []).map((it: any) => ({ id: it.id, label: it.label || '' }));
|
||||
|
||||
17
frontend/src/config/wixConfig.ts
Normal file
17
frontend/src/config/wixConfig.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const WIX_CLIENT_ID = process.env.REACT_APP_WIX_CLIENT_ID || '';
|
||||
|
||||
export const WIX_NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || '';
|
||||
|
||||
export function getWixRedirectOrigin(): string {
|
||||
if (typeof window === 'undefined') return WIX_NGROK_ORIGIN;
|
||||
return window.location.origin.includes('localhost') && WIX_NGROK_ORIGIN
|
||||
? WIX_NGROK_ORIGIN
|
||||
: window.location.origin;
|
||||
}
|
||||
|
||||
export function getWixTrustedOrigins(): string[] {
|
||||
if (typeof window === 'undefined') return WIX_NGROK_ORIGIN ? [WIX_NGROK_ORIGIN] : [];
|
||||
const origins = [window.location.origin];
|
||||
if (WIX_NGROK_ORIGIN) origins.push(WIX_NGROK_ORIGIN);
|
||||
return origins;
|
||||
}
|
||||
@@ -95,11 +95,14 @@ const restoreInitialState = () => {
|
||||
seoAnalysis = readLS<BlogSEOAnalyzeResponse | null>('blog_seo_analysis', null);
|
||||
seoMetadata = readLS<BlogSEOMetadataResponse | null>('blog_seo_metadata', null);
|
||||
|
||||
// Restore section images
|
||||
// Restore section images (log only once per session, not on every hook mount)
|
||||
const savedSectionImages = readLS<Record<string, string> | null>('blog_section_images', null);
|
||||
if (savedSectionImages && Object.keys(savedSectionImages).length > 0) {
|
||||
sectionImages = savedSectionImages;
|
||||
console.log(`[SectionImages] Restored ${Object.keys(sectionImages).length} images from localStorage`);
|
||||
if (!(window as any).__sectionImagesLogged) {
|
||||
console.log(`[SectionImages] Restored ${Object.keys(sectionImages).length} images from localStorage`);
|
||||
(window as any).__sectionImagesLogged = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during initial state restoration:', error);
|
||||
@@ -137,6 +140,7 @@ export const useBlogWriterState = () => {
|
||||
const [seoAnalysis, setSeoAnalysis] = useState<BlogSEOAnalyzeResponse | null>(initialState.seoAnalysis);
|
||||
const [genMode, setGenMode] = useState<'draft' | 'polished'>('polished');
|
||||
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(initialState.seoMetadata);
|
||||
const [introduction, setIntroduction] = useState<string>(localStorage.getItem('blog_introduction') || '');
|
||||
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
|
||||
const [outlineTaskId, setOutlineTaskId] = useState<string | null>(null);
|
||||
const [flowAnalysisCompleted, setFlowAnalysisCompleted] = useState<boolean>(false);
|
||||
@@ -246,15 +250,32 @@ export const useBlogWriterState = () => {
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (Object.keys(sectionImages).length > 0) {
|
||||
localStorage.setItem('blog_section_images', JSON.stringify(sectionImages));
|
||||
const serialized = JSON.stringify(sectionImages);
|
||||
// Warn if approaching localStorage quota (~5MB)
|
||||
if (serialized.length > 4_000_000) {
|
||||
console.warn(`[SectionImages] Approaching localStorage quota: ${(serialized.length / 1024 / 1024).toFixed(1)}MB`);
|
||||
}
|
||||
localStorage.setItem('blog_section_images', serialized);
|
||||
} else {
|
||||
localStorage.removeItem('blog_section_images');
|
||||
// Only remove if we have previously saved images (avoid clearing on transient empty state)
|
||||
if (localStorage.getItem('blog_section_images')) {
|
||||
localStorage.removeItem('blog_section_images');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[SectionImages] Failed to persist to localStorage via effect:', e);
|
||||
}
|
||||
}, [sectionImages]);
|
||||
|
||||
// Persist introduction to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (introduction) {
|
||||
localStorage.setItem('blog_introduction', introduction);
|
||||
}
|
||||
} catch {}
|
||||
}, [introduction]);
|
||||
|
||||
// Persist sections to blogWriterCache whenever they change
|
||||
useEffect(() => {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
@@ -410,6 +431,7 @@ export const useBlogWriterState = () => {
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
sections,
|
||||
introduction,
|
||||
seoAnalysis,
|
||||
genMode,
|
||||
seoMetadata,
|
||||
@@ -433,6 +455,7 @@ export const useBlogWriterState = () => {
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setIntroduction,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
setSeoMetadata,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import { BlogOutlineSection } from '../services/blogWriterApi';
|
||||
|
||||
marked.use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
});
|
||||
|
||||
const countWords = (text: string): number =>
|
||||
text.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export const useMarkdownProcessor = (
|
||||
outline: BlogOutlineSection[],
|
||||
sections: Record<string, string>
|
||||
@@ -10,89 +20,27 @@ export const useMarkdownProcessor = (
|
||||
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
|
||||
}, [outline, sections]);
|
||||
|
||||
const convertMarkdownToHTML = useCallback((md: string) => {
|
||||
const convertMarkdownToHTML = useCallback((md: string): string => {
|
||||
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;
|
||||
try {
|
||||
const rendered = marked.parse(md);
|
||||
const html = typeof rendered === 'string' ? rendered : '';
|
||||
return html.replace(/<table>/g, '<div class="table-wrapper"><table>').replace(/<\/table>/g, '</table></div>');
|
||||
} catch {
|
||||
return `<p style="color:#991b1b;">Could not render this section. Unexpected markdown syntax encountered.</p>`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTotalWords = useCallback(() => {
|
||||
const fullMarkdown = buildFullMarkdown();
|
||||
return fullMarkdown.split(/\s+/).filter(word => word.length > 0).length;
|
||||
}, [buildFullMarkdown]);
|
||||
const getTotalWords = useCallback(() => countWords(buildFullMarkdown()), [buildFullMarkdown]);
|
||||
|
||||
const getSectionWordCount = useCallback((sectionId: string) => {
|
||||
const content = sections[sectionId] || '';
|
||||
return content.split(/\s+/).filter(word => word.length > 0).length;
|
||||
}, [sections]);
|
||||
const getSectionWordCount = useCallback((sectionId: string) => countWords(sections[sectionId] || ''), [sections]);
|
||||
|
||||
const getOutlineStats = useCallback(() => {
|
||||
const totalWords = getTotalWords();
|
||||
const totalSections = outline.length;
|
||||
const totalSubheadings = outline.reduce((sum, section) => sum + section.subheadings.length, 0);
|
||||
const totalKeyPoints = outline.reduce((sum, section) => sum + section.key_points.length, 0);
|
||||
|
||||
|
||||
return {
|
||||
totalWords,
|
||||
totalSections,
|
||||
|
||||
@@ -39,6 +39,15 @@ export const usePhaseNavigation = (
|
||||
initialPhase: adjustedInitialPhase,
|
||||
});
|
||||
|
||||
// Read publish completion flag (persists across refreshes)
|
||||
const publishCompleted = ((): boolean => {
|
||||
try {
|
||||
return localStorage.getItem('blog_publish_completed') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Determine phase states based on current data
|
||||
const phases = useMemo((): Phase[] => {
|
||||
const researchCompleted = !!research;
|
||||
@@ -88,13 +97,13 @@ export const usePhaseNavigation = (
|
||||
name: 'Publish',
|
||||
icon: '🚀',
|
||||
description: 'Publish your blog post',
|
||||
completed: false,
|
||||
completed: publishCompleted,
|
||||
current: core.currentPhase === 'publish',
|
||||
disabled: !seoCompleted,
|
||||
disabled: !seoCompleted && !publishCompleted,
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase]);
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, publishCompleted]);
|
||||
|
||||
// Shared validation: redirect if current phase is disabled
|
||||
usePhaseValidation(
|
||||
@@ -117,6 +126,11 @@ export const usePhaseNavigation = (
|
||||
return;
|
||||
}
|
||||
|
||||
// If publish was already completed, don't auto-nav away from it
|
||||
if (publishCompleted && core.currentPhase === 'publish') {
|
||||
return;
|
||||
}
|
||||
|
||||
const canNavigateTo = (phaseId: string): boolean => {
|
||||
const phase = phases.find(p => p.id === phaseId);
|
||||
return !!phase && !phase.disabled;
|
||||
@@ -149,7 +163,7 @@ export const usePhaseNavigation = (
|
||||
core.setCurrentPhase('publish');
|
||||
}
|
||||
}
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, core.userSelectedPhase, phases]);
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, core.currentPhase, core.userSelectedPhase, phases, publishCompleted]);
|
||||
|
||||
const navigateToPhase = useCallback(
|
||||
(phaseId: string) => core.navigateToPhase(phaseId, phases),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { BlogSEOMetadataResponse } from '../services/blogWriterApi';
|
||||
import { storeEncrypted, readEncrypted, storeEncryptedSync } from '../utils/wixTokenStorage';
|
||||
import { markConnectionHandled, isAlreadyHandled } from '../utils/wixConnectionDedup';
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
@@ -14,6 +16,49 @@ export interface WixPublishResult {
|
||||
post_id?: string;
|
||||
message: string;
|
||||
action_required?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const WIX_TOKEN_KEY = 'wix_access_token';
|
||||
const WIX_CONNECTED_KEY = 'wix_connected';
|
||||
|
||||
const CONTENT_MAX_LENGTH = 50000;
|
||||
const CONTENT_WARNING_LENGTH = 30000;
|
||||
|
||||
function validatePublishContent(content: string): { valid: boolean; warning?: string } {
|
||||
if (!content || !content.trim()) {
|
||||
return { valid: false, warning: 'Cannot publish: content is empty.' };
|
||||
}
|
||||
const stripped = content.trim();
|
||||
if (stripped.length < 10) {
|
||||
return { valid: false, warning: 'Content is too short to publish. Write more before publishing.' };
|
||||
}
|
||||
let boldOpen = 0;
|
||||
let i = 0;
|
||||
while (i < stripped.length) {
|
||||
if (i < stripped.length - 1 && stripped[i] === '*' && stripped[i + 1] === '*') {
|
||||
boldOpen++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (boldOpen % 2 !== 0) {
|
||||
return { valid: false, warning: 'Content has an unmatched ** (bold marker). Please fix formatting before publishing.' };
|
||||
}
|
||||
let codeTicks = 0;
|
||||
const codeMatch = stripped.match(/```/g);
|
||||
if (codeMatch) codeTicks = codeMatch.length;
|
||||
if (codeTicks % 2 !== 0) {
|
||||
return { valid: false, warning: 'Content has an unmatched ``` (code block marker). Please fix formatting before publishing.' };
|
||||
}
|
||||
if (stripped.length > CONTENT_MAX_LENGTH) {
|
||||
return { valid: false, warning: `Content is ${Math.round(stripped.length / 1000)}K characters — maximum is ${CONTENT_MAX_LENGTH / 1000}K. Please shorten your content.` };
|
||||
}
|
||||
if (stripped.length > CONTENT_WARNING_LENGTH) {
|
||||
return { valid: true, warning: `Content is ${Math.round(stripped.length / 1000)}K characters. Very long posts may take longer to publish on Wix.` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function useWixPublish() {
|
||||
@@ -23,26 +68,42 @@ export function useWixPublish() {
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const pendingPublishRef = useRef<(() => Promise<WixPublishResult>) | null>(null);
|
||||
|
||||
const clearStaleWixState = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(WIX_CONNECTED_KEY);
|
||||
localStorage.removeItem(`wix_ek_${WIX_TOKEN_KEY}`);
|
||||
sessionStorage.removeItem(WIX_CONNECTED_KEY);
|
||||
sessionStorage.removeItem('wix_tokens');
|
||||
sessionStorage.removeItem('wix_site_info');
|
||||
window.name = '';
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const checkWixStatus = useCallback(async () => {
|
||||
setCheckingWix(true);
|
||||
try {
|
||||
// 1. Cross-tab handoff from OAuth callback (ngrok → localhost redirect)
|
||||
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);
|
||||
await storeEncrypted(WIX_TOKEN_KEY, payload.access_token);
|
||||
}
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
markConnectionHandled();
|
||||
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
|
||||
sessionStorage.setItem(WIX_CONNECTED_KEY, 'true');
|
||||
window.name = '';
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: payload.site_info });
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 2. PRIMARY: Ask backend — it actually validates the DB token against Wix APIs
|
||||
try {
|
||||
const resp = await apiClient.get('/api/wix/connection/status');
|
||||
if (resp.data?.connected) {
|
||||
// Backend says token is valid — sync local state and show connected
|
||||
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
|
||||
setWixStatus({
|
||||
connected: true,
|
||||
has_permissions: resp.data.has_permissions ?? true,
|
||||
@@ -50,24 +111,27 @@ export function useWixPublish() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (localStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
// Backend says NOT connected (401 or valid response with connected:false)
|
||||
// → token expired / revoked / missing → clear all local state
|
||||
clearStaleWixState();
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
// Backend error (network, 500, etc.) — can't determine status
|
||||
// Fall through to localStorage hint only if we have no other info
|
||||
console.warn('[Wix] Backend connection check failed:', err?.message || err);
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
return;
|
||||
}
|
||||
// 3. FALLBACK: localStorage is only a hint, never authoritative
|
||||
const localConnected = localStorage.getItem(WIX_CONNECTED_KEY) === 'true';
|
||||
const sessionConnected = sessionStorage.getItem(WIX_CONNECTED_KEY) === 'true';
|
||||
const urlConnected = new URLSearchParams(window.location.search).get('wix_connected') === 'true';
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
if (localConnected || sessionConnected || urlConnected) {
|
||||
// We have a hint that user was connected, but backend couldn't confirm
|
||||
// Show as connected but with warning — user may need to reconnect
|
||||
console.warn('[Wix] Showing cached connection state — backend validation failed. User may need to reconnect.');
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +141,7 @@ export function useWixPublish() {
|
||||
} finally {
|
||||
setCheckingWix(false);
|
||||
}
|
||||
}, []);
|
||||
}, [clearStaleWixState]);
|
||||
|
||||
useEffect(() => {
|
||||
checkWixStatus();
|
||||
@@ -85,21 +149,25 @@ export function useWixPublish() {
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'wix_connected' && e.newValue === 'true') {
|
||||
if (isAlreadyHandled()) return;
|
||||
if (e.key === WIX_CONNECTED_KEY && e.newValue === 'true') {
|
||||
markConnectionHandled();
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
if (e.key === 'wix_access_token' && e.newValue) {
|
||||
if (e.key === `wix_ek_${WIX_TOKEN_KEY}` && e.newValue) {
|
||||
setWixStatus(prev => prev ? prev : { connected: true, has_permissions: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
|
||||
const msgHandler = (e: MessageEvent) => {
|
||||
if (isAlreadyHandled()) return;
|
||||
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');
|
||||
markConnectionHandled();
|
||||
if (e.data.access_token) storeEncryptedSync(WIX_TOKEN_KEY, e.data.access_token);
|
||||
localStorage.setItem(WIX_CONNECTED_KEY, 'true');
|
||||
sessionStorage.setItem(WIX_CONNECTED_KEY, 'true');
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: e.data.site_info });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
@@ -132,33 +200,42 @@ export function useWixPublish() {
|
||||
}
|
||||
|
||||
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 validation = validatePublishContent(content);
|
||||
if (!validation.valid) {
|
||||
return { success: false, message: validation.warning || 'Content validation failed.' };
|
||||
}
|
||||
|
||||
let frontendAccessToken: string | undefined;
|
||||
|
||||
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
|
||||
try {
|
||||
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);
|
||||
if (payload.access_token) {
|
||||
await storeEncrypted(WIX_TOKEN_KEY, payload.access_token);
|
||||
frontendAccessToken = payload.access_token;
|
||||
}
|
||||
window.name = '';
|
||||
}
|
||||
} catch {}
|
||||
if (!accessToken) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!frontendAccessToken) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('wix_tokens');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
accessToken = parsed.accessToken?.value || parsed.access_token || undefined;
|
||||
frontendAccessToken = parsed.accessToken?.value || parsed.access_token || undefined;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!accessToken) {
|
||||
|
||||
if (!frontendAccessToken) {
|
||||
try {
|
||||
accessToken = localStorage.getItem('wix_access_token') || undefined;
|
||||
frontendAccessToken = (await readEncrypted(WIX_TOKEN_KEY)) || undefined;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.log('[WixPublish] Publishing — backend DB is authoritative token source; frontend token sent as fallback only.');
|
||||
|
||||
const response = await apiClient.post('/api/wix/publish', {
|
||||
title,
|
||||
content,
|
||||
@@ -166,11 +243,13 @@ export function useWixPublish() {
|
||||
category_names: metadata?.blog_categories || [],
|
||||
tag_names: metadata?.blog_tags || [],
|
||||
publish: true,
|
||||
...(accessToken ? { access_token: accessToken } : {}),
|
||||
...(frontendAccessToken ? { access_token: frontendAccessToken } : {}),
|
||||
seo_metadata: metadata ? {
|
||||
seo_title: metadata.seo_title,
|
||||
meta_description: metadata.meta_description,
|
||||
focus_keyword: metadata.focus_keyword,
|
||||
url_slug: metadata.url_slug,
|
||||
blog_categories: metadata.blog_categories || [],
|
||||
blog_tags: metadata.blog_tags || [],
|
||||
social_hashtags: metadata.social_hashtags || [],
|
||||
open_graph: metadata.open_graph || {},
|
||||
@@ -181,6 +260,7 @@ export function useWixPublish() {
|
||||
|
||||
if (response.data.success) {
|
||||
const url = response.data.url;
|
||||
const warning = response.data.warning;
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
@@ -188,6 +268,7 @@ export function useWixPublish() {
|
||||
message: url
|
||||
? `Blog post published to Wix! View it here: ${url}`
|
||||
: 'Blog post published successfully to Wix!',
|
||||
...(warning ? { warning } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -196,6 +277,8 @@ export function useWixPublish() {
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
clearStaleWixState();
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
pendingPublishRef.current = async () => publishToWix(content, metadata);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
@@ -209,7 +292,7 @@ export function useWixPublish() {
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, [clearStaleWixState]);
|
||||
|
||||
const handleWixConnectionSuccess = useCallback(async () => {
|
||||
await checkWixStatus();
|
||||
@@ -243,5 +326,6 @@ export function useWixPublish() {
|
||||
setShowWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
validateWixContent: validatePublishContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,9 +102,11 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
|
||||
// Accept messages only from the popup we opened and from trusted origins
|
||||
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || '';
|
||||
const productionOrigin = 'https://alwrity-ai.vercel.app';
|
||||
const trustedOrigins = [window.location.origin, ngrokOrigin, productionOrigin];
|
||||
const trustedOrigins = [window.location.origin];
|
||||
if (ngrokOrigin) trustedOrigins.push(ngrokOrigin);
|
||||
trustedOrigins.push(productionOrigin);
|
||||
|
||||
if (event.source !== popup) return;
|
||||
if (!trustedOrigins.includes(event.origin)) {
|
||||
|
||||
@@ -1,64 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Typography, Chip, Button } from '@mui/material';
|
||||
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
|
||||
import CommitteeSummary from '../components/TeamActivity/CommitteeSummary';
|
||||
import CommitteeAuditTable from '../components/TeamActivity/CommitteeAuditTable';
|
||||
import AlertBanner from '../components/TeamActivity/AlertBanner';
|
||||
import AgentStatusPanel from '../components/TeamActivity/AgentStatusPanel';
|
||||
import ActivityLog from '../components/TeamActivity/ActivityLog';
|
||||
import QualityAuditPanel from '../components/TeamActivity/QualityAuditPanel';
|
||||
import TrendSignalsPanel from '../components/TeamActivity/TrendSignalsPanel';
|
||||
import AgentHelpModal from '../components/TeamActivity/AgentHelpModal';
|
||||
|
||||
const TeamActivityPage: React.FC = () => {
|
||||
const { runs, events, alerts, approvals, connectionMode } = useAgentHuddleFeed();
|
||||
const [auditMode, setAuditMode] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Team Activity</Typography>
|
||||
<Chip label={connectionMode === 'sse' ? 'Live stream' : 'Polling fallback'} color={connectionMode === 'sse' ? 'success' : 'warning'} />
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)' }}>
|
||||
Team Activity
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={connectionMode === 'sse' ? 'Live' : 'Polling'}
|
||||
size="small"
|
||||
sx={{ height: 24, fontSize: 11, fontWeight: 600, bgcolor: connectionMode === 'sse' ? 'rgba(76,175,80,0.15)' : 'rgba(255,152,0,0.15)', color: connectionMode === 'sse' ? '#4caf50' : '#ff9800' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant={auditMode ? 'contained' : 'outlined'}
|
||||
onClick={() => setAuditMode(!auditMode)}
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
borderRadius: 2,
|
||||
...(auditMode
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(102,126,234,0.8), rgba(118,75,162,0.8))',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(102,126,234,0.3)',
|
||||
}
|
||||
: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{auditMode ? '← Summary' : 'Advanced Audit ▾'}
|
||||
</Button>
|
||||
<AgentHelpModal />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Run lifecycle updates</Typography>
|
||||
<List dense>
|
||||
{runs.slice(0, 20).map((run) => (
|
||||
<ListItem key={run.id}><ListItemText primary={`${run.agent_type || 'agent'} · ${run.status}`} secondary={run.result_summary || run.finished_at || 'In progress'} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
{auditMode ? (
|
||||
<CommitteeAuditTable events={events} />
|
||||
) : (
|
||||
<>
|
||||
{/* 1. Alerts + Approvals need attention */}
|
||||
<AlertBanner alerts={alerts} approvals={approvals} />
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>New events</Typography>
|
||||
<List dense>
|
||||
{events.slice(0, 20).map((event) => (
|
||||
<ListItem key={event.id}><ListItemText primary={`${event.agent_type || 'agent'} · ${event.event_type}`} secondary={event.message || event.created_at} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
{/* 2. Committee decision brief */}
|
||||
<CommitteeSummary events={events} />
|
||||
|
||||
<Grid item xs={12}><Divider /></Grid>
|
||||
{/* 3. Quality audit (ContentGuardianAgent) */}
|
||||
<QualityAuditPanel events={events} />
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Alert deltas</Typography>
|
||||
<List dense>
|
||||
{alerts.slice(0, 20).map((alert) => (
|
||||
<ListItem key={alert.id}><ListItemText primary={alert.title || 'Alert'} secondary={alert.message} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
{/* 4. Trend signals (TrendSurferAgent) */}
|
||||
<TrendSignalsPanel events={events} />
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Approval deltas</Typography>
|
||||
<List dense>
|
||||
{approvals.slice(0, 20).map((approval) => (
|
||||
<ListItem key={approval.id}><ListItemText primary={`${approval.action_type || 'Action'} · ${approval.status}`} secondary={approval.created_at} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/* 5. Agent health at a glance */}
|
||||
<AgentStatusPanel events={events} runs={runs} alerts={alerts} />
|
||||
|
||||
{/* 6. Raw activity feed (collapsed by default) */}
|
||||
<ActivityLog runs={runs} events={events} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -183,6 +183,7 @@ export interface BlogSEOAnalyzeResponse {
|
||||
|
||||
export interface BlogSEOApplyRecommendationsRequest {
|
||||
title: string;
|
||||
introduction?: string;
|
||||
sections: Array<{ id: string; heading: string; content: string }>;
|
||||
outline: BlogOutlineSection[];
|
||||
research: Record<string, any>;
|
||||
@@ -195,6 +196,7 @@ export interface BlogSEOApplyRecommendationsRequest {
|
||||
export interface BlogSEOApplyRecommendationsResponse {
|
||||
success: boolean;
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
sections: Array<{ id: string; heading: string; content: string; notes?: string[] }>;
|
||||
applied?: Array<{ category: string; summary: string }>;
|
||||
error?: string;
|
||||
@@ -390,7 +392,7 @@ export const blogWriterApi = {
|
||||
},
|
||||
|
||||
async applySeoRecommendations(payload: BlogSEOApplyRecommendationsRequest): Promise<BlogSEOApplyRecommendationsResponse> {
|
||||
const { data } = await apiClient.post('/api/blog/seo/apply-recommendations', payload);
|
||||
const { data } = await aiApiClient.post('/api/blog/seo/apply-recommendations', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
121
frontend/src/utils/getSectionDiffs.ts
Normal file
121
frontend/src/utils/getSectionDiffs.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export interface DiffSegment {
|
||||
value: string;
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
export interface SectionDiff {
|
||||
heading: string;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
segments: DiffSegment[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface DiffPreviewData {
|
||||
introductionDiff: DiffSegment[] | null;
|
||||
introductionChanged: boolean;
|
||||
originalIntroduction: string;
|
||||
newIntroduction: string;
|
||||
sectionDiffs: SectionDiff[];
|
||||
}
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text.split(/(\s+|[.,!?;:()\[\]{}"'——-])/).filter(Boolean);
|
||||
}
|
||||
|
||||
function computeLCS(oldTokens: string[], newTokens: string[]): number[][] {
|
||||
const m = oldTokens.length;
|
||||
const n = newTokens.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldTokens[i - 1] === newTokens[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp;
|
||||
}
|
||||
|
||||
function backtrackLCS(
|
||||
dp: number[][], oldTokens: string[], newTokens: string[],
|
||||
i: number, j: number, oldIdx: number, newIdx: number,
|
||||
segments: { type: 'same' | 'removed' | 'added'; token: string }[]
|
||||
) {
|
||||
if (i === 0 && j === 0) return;
|
||||
if (i > 0 && j > 0 && oldTokens[i - 1] === newTokens[j - 1]) {
|
||||
backtrackLCS(dp, oldTokens, newTokens, i - 1, j - 1, oldIdx - 1, newIdx - 1, segments);
|
||||
segments.push({ type: 'same', token: oldTokens[i - 1] });
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
backtrackLCS(dp, oldTokens, newTokens, i, j - 1, oldIdx, newIdx - 1, segments);
|
||||
segments.push({ type: 'added', token: newTokens[j - 1] });
|
||||
} else if (i > 0) {
|
||||
backtrackLCS(dp, oldTokens, newTokens, i - 1, j, oldIdx - 1, newIdx, segments);
|
||||
segments.push({ type: 'removed', token: oldTokens[i - 1] });
|
||||
}
|
||||
}
|
||||
|
||||
function computeWordDiff(original: string, updated: string): DiffSegment[] {
|
||||
const oldTokens = tokenize(original || '');
|
||||
const newTokens = tokenize(updated || '');
|
||||
const dp = computeLCS(oldTokens, newTokens);
|
||||
const aligned: { type: 'same' | 'removed' | 'added'; token: string }[] = [];
|
||||
backtrackLCS(dp, oldTokens, newTokens, oldTokens.length, newTokens.length, oldTokens.length, newTokens.length, aligned);
|
||||
|
||||
const merged: DiffSegment[] = [];
|
||||
for (const item of aligned) {
|
||||
if (item.type === 'same') {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && !last.added && !last.removed) {
|
||||
last.value += item.token;
|
||||
} else {
|
||||
merged.push({ value: item.token });
|
||||
}
|
||||
} else if (item.type === 'removed') {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.removed) {
|
||||
last.value += item.token;
|
||||
} else {
|
||||
merged.push({ value: item.token, removed: true });
|
||||
}
|
||||
} else {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.added) {
|
||||
last.value += item.token;
|
||||
} else {
|
||||
merged.push({ value: item.token, added: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function getSectionDiffs(
|
||||
outlineHeadings: { id: string; heading: string }[],
|
||||
originalSections: Record<string, string>,
|
||||
newSections: Record<string, string>,
|
||||
originalIntroduction?: string,
|
||||
newIntroduction?: string
|
||||
): DiffPreviewData {
|
||||
const sectionDiffs: SectionDiff[] = outlineHeadings.map(({ id, heading }) => {
|
||||
const originalContent = originalSections[id] || '';
|
||||
const newContent = newSections[id] || '';
|
||||
const segments = computeWordDiff(originalContent, newContent);
|
||||
const changed = segments.some(s => s.added || s.removed);
|
||||
return { heading, originalContent, newContent, segments, changed };
|
||||
});
|
||||
|
||||
let introductionDiff: DiffSegment[] | null = null;
|
||||
let introductionChanged = false;
|
||||
if (originalIntroduction !== undefined && newIntroduction !== undefined) {
|
||||
introductionDiff = computeWordDiff(originalIntroduction || '', newIntroduction || '');
|
||||
introductionChanged = introductionDiff && introductionDiff.some(s => s.added || s.removed);
|
||||
}
|
||||
|
||||
return { introductionDiff, introductionChanged, originalIntroduction: originalIntroduction || '', newIntroduction: newIntroduction || '', sectionDiffs };
|
||||
}
|
||||
|
||||
export { computeWordDiff };
|
||||
31
frontend/src/utils/wixConnectionDedup.ts
Normal file
31
frontend/src/utils/wixConnectionDedup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const DEDUP_KEY = 'wix_oauth_handled';
|
||||
const DEDUP_TTL_MS = 5000;
|
||||
|
||||
let _moduleLevelHandled = false;
|
||||
|
||||
export function markConnectionHandled(): void {
|
||||
_moduleLevelHandled = true;
|
||||
try {
|
||||
sessionStorage.setItem(DEDUP_KEY, Date.now().toString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isAlreadyHandled(): boolean {
|
||||
if (_moduleLevelHandled) return true;
|
||||
try {
|
||||
const ts = sessionStorage.getItem(DEDUP_KEY);
|
||||
if (ts) {
|
||||
const elapsed = Date.now() - parseInt(ts, 10);
|
||||
if (elapsed < DEDUP_TTL_MS) return true;
|
||||
sessionStorage.removeItem(DEDUP_KEY);
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clearConnectionHandled(): void {
|
||||
_moduleLevelHandled = false;
|
||||
try {
|
||||
sessionStorage.removeItem(DEDUP_KEY);
|
||||
} catch {}
|
||||
}
|
||||
83
frontend/src/utils/wixTokenStorage.ts
Normal file
83
frontend/src/utils/wixTokenStorage.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
const STORAGE_PREFIX = 'wix_ek_';
|
||||
const IV_LENGTH = 12;
|
||||
const SALT_LENGTH = 16;
|
||||
|
||||
async function _deriveKey(): Promise<CryptoKey> {
|
||||
const raw = process.env.REACT_APP_WIX_CLIENT_ID || 'alwrity-wix-encryption-key';
|
||||
const encoded = new TextEncoder().encode(raw);
|
||||
const salt = new TextEncoder().encode('wix-token-encryption-salt-v1');
|
||||
const baseKey = await crypto.subtle.importKey('raw', encoded, { name: 'PBKDF2' }, false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
let _keyPromise: Promise<CryptoKey> | null = null;
|
||||
function getKey(): Promise<CryptoKey> {
|
||||
if (!_keyPromise) _keyPromise = _deriveKey();
|
||||
return _keyPromise;
|
||||
}
|
||||
|
||||
export async function encryptToken(plaintext: string): Promise<string> {
|
||||
const key = await getKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
const cipher = new Uint8Array(cipherBuf);
|
||||
const combined = new Uint8Array(IV_LENGTH + cipher.length);
|
||||
combined.set(iv, 0);
|
||||
combined.set(cipher, IV_LENGTH);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
export async function decryptToken(stored: string): Promise<string> {
|
||||
const key = await getKey();
|
||||
const raw = Uint8Array.from(atob(stored), c => c.charCodeAt(0));
|
||||
const iv = raw.slice(0, IV_LENGTH);
|
||||
const cipher = raw.slice(IV_LENGTH);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
export async function storeEncrypted(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
const encrypted = await encryptToken(value);
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${key}`, encrypted);
|
||||
} catch {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function storeEncryptedSync(key: string, value: string): void {
|
||||
const combined = btoa(value);
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${key}`, combined);
|
||||
}
|
||||
|
||||
export async function readEncrypted(key: string): Promise<string | null> {
|
||||
const prefixedKey = `${STORAGE_PREFIX}${key}`;
|
||||
const prefixed = localStorage.getItem(prefixedKey);
|
||||
if (prefixed) {
|
||||
try {
|
||||
return await decryptToken(prefixed);
|
||||
} catch {
|
||||
try { return atob(prefixed); } catch { return prefixed; }
|
||||
}
|
||||
}
|
||||
const legacy = localStorage.getItem(key);
|
||||
if (legacy) {
|
||||
try {
|
||||
await storeEncrypted(key, legacy);
|
||||
localStorage.removeItem(key);
|
||||
} catch {}
|
||||
return legacy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSecureStorageAvailable(): boolean {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined';
|
||||
}
|
||||
Reference in New Issue
Block a user