ALwrity HALLUCINATION DETECTOR AND ASSISTIVE WRITING
This commit is contained in:
257
frontend/src/components/TextEditor/CitationHoverHandler.tsx
Normal file
257
frontend/src/components/TextEditor/CitationHoverHandler.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface CitationHoverHandlerProps {
|
||||
researchSources: any[];
|
||||
}
|
||||
|
||||
// Extend Element interface for our custom property
|
||||
interface ExtendedElement extends Element {
|
||||
_liwTip?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const CitationHoverHandler: React.FC<CitationHoverHandlerProps> = ({ researchSources }) => {
|
||||
useEffect(() => {
|
||||
if (!researchSources || researchSources.length === 0) return;
|
||||
|
||||
console.log('🔍 [Citation Hover] useEffect triggered with', researchSources.length, 'sources');
|
||||
|
||||
// Keep track of currently open tooltip
|
||||
let currentOpenTooltip: HTMLDivElement | null = null;
|
||||
|
||||
const initCitationHover = () => {
|
||||
try {
|
||||
console.log('🔍 [Citation Hover] Script starting...');
|
||||
console.log('🔍 [Citation Hover] Research sources count:', researchSources.length);
|
||||
|
||||
// Test if script is running
|
||||
document.body.style.setProperty('--citation-hover-active', 'true');
|
||||
console.log('🔍 [Citation Hover] Script is running, CSS variable set');
|
||||
|
||||
// Wait for content to be rendered
|
||||
const waitForCitations = () => {
|
||||
const citations = document.querySelectorAll('.liw-cite');
|
||||
console.log('🔍 [Citation Hover] Looking for citations, found:', citations.length);
|
||||
|
||||
if (citations.length === 0) {
|
||||
// If no citations found, wait a bit and try again
|
||||
console.log('🔍 [Citation Hover] No citations found, waiting...');
|
||||
setTimeout(waitForCitations, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 [Citation Hover] Found', citations.length, 'citation elements');
|
||||
citations.forEach((cite, idx) => {
|
||||
console.log(`🔍 [Citation Hover] Citation ${idx}: ${cite.outerHTML}`);
|
||||
console.log(`🔍 [Citation Hover] Citation classes: ${cite.className}`);
|
||||
console.log(`🔍 [Citation Hover] Citation data-source-index: ${cite.getAttribute('data-source-index')}`);
|
||||
});
|
||||
setupCitationHover();
|
||||
};
|
||||
|
||||
const setupCitationHover = () => {
|
||||
console.log('🔍 [Citation Hover] Initializing hover functionality...');
|
||||
const data = researchSources;
|
||||
console.log('🔍 [Citation Hover] Research data loaded:', data.length, 'sources');
|
||||
|
||||
const openOverlay = (idx: string, src: any) => {
|
||||
console.log('🔍 [Citation Hover] Opening overlay for source', idx, src);
|
||||
const existing = document.getElementById('liw-cite-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'liw-cite-overlay';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.inset = '0';
|
||||
overlay.style.background = 'rgba(0,0,0,0.35)';
|
||||
overlay.style.backdropFilter = 'blur(2px)';
|
||||
overlay.style.zIndex = '100000';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.width = 'min(720px, 92vw)';
|
||||
modal.style.maxHeight = '80vh';
|
||||
modal.style.overflow = 'auto';
|
||||
modal.style.borderRadius = '14px';
|
||||
modal.style.background = 'linear-gradient(180deg, #ffffff, #f8fdff)';
|
||||
modal.style.border = '1px solid #cfe9f7';
|
||||
modal.style.boxShadow = '0 24px 80px rgba(10,102,194,0.25)';
|
||||
modal.style.padding = '18px 20px';
|
||||
|
||||
const title = (src.title || 'Untitled').replace(/</g, '<');
|
||||
const url = (src.url || '').replace(/</g, '<');
|
||||
const sourceType = src.source_type ? String(src.source_type).replace('_', ' ') : '';
|
||||
|
||||
modal.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
'<div style="font-size:16px;font-weight:800;color:#0a66c2">Source ' + idx + '</div>' +
|
||||
'<button id="liw-cite-close" style="border:none;background:#eff6ff;color:#0a66c2;border-radius:8px;padding:8px 12px;cursor:pointer;font-weight:700">✕ Close</button>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:18px;font-weight:700;color:#1f2937;margin-bottom:8px">' + title + '</div>' +
|
||||
'<a href="' + (src.url || '#') + '" target="_blank" style="display:inline-block;color:#0a66c2;text-decoration:none;margin-bottom:12px;font-size:14px;font-weight:600;">View Source →</a>' +
|
||||
(src.content ? '<div style="margin-bottom:16px;color:#374151;font-size:14px;line-height:1.6;background:#f9fafb;padding:16px;border-radius:8px;border-left:4px solid #0a66c2;">' + src.content + '</div>' : '') +
|
||||
'<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">' +
|
||||
(typeof src.relevance_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Relevance: ' + Math.round(src.relevance_score * 100) + '%</span>' : '') +
|
||||
(typeof src.credibility_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Credibility: ' + Math.round(src.credibility_score * 100) + '%</span>' : '') +
|
||||
(typeof src.domain_authority === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:8px 12px;font-size:13px;color:#055a8c;font-weight:600">Authority: ' + Math.round(src.domain_authority * 100) + '%</span>' : '') +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:16px;color:#6b7280;font-size:13px;padding-top:12px;border-top:1px solid #e5e7eb">' +
|
||||
(src.source_type ? '<div>Type: <span style="color:#374151;font-weight:600">' + src.source_type.replace('_', ' ') + '</span></div>' : '') +
|
||||
(src.publication_date ? '<div>Published: <span style="color:#374151;font-weight:600">' + src.publication_date + '</span></div>' : '') +
|
||||
'</div>' +
|
||||
(src.raw_result ? '<div style="color:#6b7280;font-size:12px;margin-top:12px;padding:8px;background:#f3f4f6;border-radius:6px;border-top:1px solid #e5e7eb;">Raw Data: ' + JSON.stringify(src.raw_result).substring(0, 150) + (JSON.stringify(src.raw_result).length > 150 ? '...' : '') + '</div>' : '');
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const close = () => {
|
||||
try { overlay.remove(); } catch(_){}
|
||||
};
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if(e.target === overlay) close();
|
||||
});
|
||||
document.getElementById('liw-cite-close')?.addEventListener('click', close);
|
||||
document.addEventListener('keydown', function esc(ev: KeyboardEvent) {
|
||||
if(ev.key === 'Escape') {
|
||||
close();
|
||||
document.removeEventListener('keydown', esc);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add event listeners directly to each citation element
|
||||
const citations = document.querySelectorAll('.liw-cite');
|
||||
|
||||
citations.forEach((cite) => {
|
||||
console.log('🔍 [Citation Hover] Adding event listeners to citation:', cite.outerHTML);
|
||||
|
||||
cite.addEventListener('mouseenter', () => {
|
||||
console.log('🔍 [Citation Hover] Mouse enter on citation:', cite.outerHTML);
|
||||
|
||||
// Close any existing tooltip first
|
||||
if (currentOpenTooltip) {
|
||||
try { currentOpenTooltip.remove(); } catch(_) {}
|
||||
currentOpenTooltip = null;
|
||||
}
|
||||
|
||||
const idx = cite.getAttribute('data-source-index');
|
||||
console.log('🔍 [Citation Hover] Citation index:', idx);
|
||||
|
||||
if (!idx) return;
|
||||
const i = parseInt(idx, 10) - 1;
|
||||
const src = data[i];
|
||||
if (!src) {
|
||||
console.log('🔍 [Citation Hover] No source found for index:', idx);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 [Citation Hover] Creating tooltip for source:', src);
|
||||
|
||||
let tip = document.createElement('div');
|
||||
tip.className = 'liw-cite-tip';
|
||||
tip.style.position = 'fixed';
|
||||
tip.style.zIndex = '99999';
|
||||
tip.style.maxWidth = '420px';
|
||||
tip.style.background = 'linear-gradient(180deg, #ffffff, #f8fdff)';
|
||||
tip.style.border = '1px solid #cfe9f7';
|
||||
tip.style.borderRadius = '10px';
|
||||
tip.style.boxShadow = '0 12px 40px rgba(10,102,194,0.18)';
|
||||
tip.style.padding = '12px 14px';
|
||||
tip.style.fontSize = '12px';
|
||||
tip.style.color = '#1f2937';
|
||||
tip.style.backdropFilter = 'blur(5px)';
|
||||
|
||||
const title = (src.title || 'Untitled').replace(/</g, '<');
|
||||
const url = (src.url || '').replace(/</g, '<');
|
||||
const sourceType = src.source_type ? String(src.source_type).replace('_', ' ') : '';
|
||||
|
||||
tip.innerHTML =
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
||||
'<div style="font-weight:700;color:#0a66c2">Source ' + idx + '</div>' +
|
||||
'<button class="liw-pin" title="Pin" style="border:none;background:#eef6ff;border-radius:8px;padding:4px 8px;cursor:pointer;color:#0a66c2;font-weight:800">📌</button>' +
|
||||
'</div>' +
|
||||
'<div style="font-weight:600;margin-bottom:6px;color:#1f2937">' + title + '</div>' +
|
||||
'<a href="' + (src.url || '#') + '" target="_blank" style="color:#0a66c2;text-decoration:none;margin-bottom:8px;display:block;font-weight:600;">View Source →</a>' +
|
||||
(src.content ? '<div style="margin-bottom:8px;color:#374151;font-size:11px;line-height:1.4;background:#f9fafb;padding:8px;border-radius:6px;border-left:3px solid #0a66c2;">' + src.content + '</div>' : '') +
|
||||
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">' +
|
||||
(typeof src.relevance_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Relevance: ' + Math.round(src.relevance_score * 100) + '%</span>' : '') +
|
||||
(typeof src.credibility_score === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Credibility: ' + Math.round(src.credibility_score * 100) + '%</span>' : '') +
|
||||
(typeof src.domain_authority === 'number' ? '<span style="background:#eef6ff;border:1px solid #d9ecff;border-radius:999px;padding:4px 8px;font-size:11px;color:#055a8c;font-weight:600">Authority: ' + Math.round(src.domain_authority * 100) + '%</span>' : '') +
|
||||
'</div>' +
|
||||
(src.source_type ? '<div style="color:#6b7280;font-size:11px;margin-bottom:4px">Type: <span style="color:#374151;font-weight:600">' + src.source_type.replace('_', ' ') + '</span></div>' : '') +
|
||||
(src.publication_date ? '<div style="color:#6b7280;font-size:11px">Published: <span style="color:#374151;font-weight:600">' + src.publication_date + '</span></div>' : '') +
|
||||
(src.raw_result ? '<div style="color:#6b7280;font-size:11px;margin-top:4px;padding:4px;background:#f3f4f6;border-radius:4px;">Raw Data: ' + JSON.stringify(src.raw_result).substring(0, 100) + (JSON.stringify(src.raw_result).length > 100 ? '...' : '') + '</div>' : '');
|
||||
|
||||
document.body.appendChild(tip);
|
||||
const rect = cite.getBoundingClientRect();
|
||||
tip.style.left = Math.min(rect.left, window.innerWidth - 460) + 'px';
|
||||
tip.style.top = (rect.bottom + 8) + 'px';
|
||||
|
||||
tip.querySelector('.liw-pin')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
openOverlay(idx, src);
|
||||
try { tip.remove(); } catch(_) {
|
||||
// Remove the custom property reference
|
||||
const extendedTip = tip as any;
|
||||
extendedTip._liwTip = undefined;
|
||||
}
|
||||
currentOpenTooltip = null;
|
||||
});
|
||||
|
||||
(cite as ExtendedElement)._liwTip = tip;
|
||||
currentOpenTooltip = tip;
|
||||
console.log('🔍 [Citation Hover] Tooltip created and positioned');
|
||||
});
|
||||
|
||||
cite.addEventListener('mouseleave', () => {
|
||||
console.log('🔍 [Citation Hover] Mouse leave on citation:', cite.outerHTML);
|
||||
const extendedCite = cite as ExtendedElement;
|
||||
if (extendedCite._liwTip) {
|
||||
try { extendedCite._liwTip.remove(); } catch(_) {}
|
||||
extendedCite._liwTip = null;
|
||||
currentOpenTooltip = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ [Citation Hover] Hover functionality initialized for', citations.length, 'citations');
|
||||
};
|
||||
|
||||
// Start waiting for citations with a longer delay to ensure content is rendered
|
||||
setTimeout(waitForCitations, 500);
|
||||
|
||||
} catch(e: any) {
|
||||
console.warn('liw cite tooltip init failed', e);
|
||||
console.error('Error details:', e);
|
||||
// Show error in UI
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.style.cssText = 'position:fixed;top:10px;right:10px;background:#ffebee;border:1px solid #f44336;border-radius:4px;padding:10px;z-index:100000;color:#c62828;';
|
||||
errorDiv.innerHTML = 'Citation hover failed: ' + e.message;
|
||||
document.body.appendChild(errorDiv);
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize citation hover after a short delay to ensure content is rendered
|
||||
const timer = setTimeout(initCitationHover, 100);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Remove any existing tooltips
|
||||
const tooltips = document.querySelectorAll('.liw-cite-tip');
|
||||
tooltips.forEach(tip => tip.remove());
|
||||
// Remove overlay if exists
|
||||
const overlay = document.getElementById('liw-cite-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
// Reset current tooltip reference
|
||||
currentOpenTooltip = null;
|
||||
};
|
||||
}, [researchSources]); // Dependency on researchSources
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CitationHoverHandler;
|
||||
279
frontend/src/components/TextEditor/ContentDisplayArea.tsx
Normal file
279
frontend/src/components/TextEditor/ContentDisplayArea.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useMemo, useEffect, useRef, useState } from 'react';
|
||||
import { formatDraftContent } from '../LinkedInWriter/utils/contentFormatters';
|
||||
import WritingAssistantCard from './WritingAssistantCard';
|
||||
import { WASuggestion } from '../../services/writingAssistantService';
|
||||
|
||||
interface ContentDisplayAreaProps {
|
||||
contentRef: React.RefObject<HTMLDivElement>;
|
||||
draft: string;
|
||||
isGenerating: boolean;
|
||||
loadingMessage: string;
|
||||
citations?: any[];
|
||||
researchSources?: any[];
|
||||
assistantOn: boolean;
|
||||
waSuggestion: WASuggestion | null;
|
||||
waError?: string | null;
|
||||
showContinuePrompt?: boolean;
|
||||
onDraftChange: (value: string) => void;
|
||||
onDismissSuggestion: () => void;
|
||||
onTextSelection: () => void;
|
||||
renderSelectionMenu: () => React.ReactNode;
|
||||
onTriggerSuggestion?: (text: string, caretIndex?: number) => void;
|
||||
onInsertWithPreview?: (text: string, caretIndex: number) => void;
|
||||
onContinueWriting?: () => void;
|
||||
}
|
||||
|
||||
const ContentDisplayArea: React.FC<ContentDisplayAreaProps> = ({
|
||||
contentRef,
|
||||
draft,
|
||||
isGenerating,
|
||||
loadingMessage,
|
||||
citations,
|
||||
researchSources,
|
||||
assistantOn,
|
||||
waSuggestion,
|
||||
waError,
|
||||
showContinuePrompt,
|
||||
onDraftChange,
|
||||
onDismissSuggestion,
|
||||
onTextSelection,
|
||||
renderSelectionMenu,
|
||||
onTriggerSuggestion,
|
||||
onInsertWithPreview,
|
||||
onContinueWriting
|
||||
}) => {
|
||||
const [localDraft, setLocalDraft] = useState<string>(draft);
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const suggestionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [caretRect, setCaretRect] = useState<{ top: number; left: number } | null>(null);
|
||||
const [currentCaretIndex, setCurrentCaretIndex] = useState<number>(0);
|
||||
|
||||
const updateCaretRect = (el: HTMLTextAreaElement) => {
|
||||
const index = el.selectionStart ?? 0;
|
||||
setCurrentCaretIndex(index);
|
||||
|
||||
const container = contentRef.current as HTMLDivElement | null;
|
||||
const containerRect = container?.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const lineHeight = 22;
|
||||
const textUntilCaret = el.value.slice(0, index);
|
||||
const lines = textUntilCaret.split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const approxCharWidth = 7.2;
|
||||
|
||||
const caretTopViewport = elRect.top + 12 + (lines.length - 1) * lineHeight;
|
||||
const caretLeftViewport = elRect.left + 12 + lastLine.length * approxCharWidth;
|
||||
|
||||
if (containerRect) {
|
||||
const top = caretTopViewport - containerRect.top + (container?.scrollTop || 0);
|
||||
const left = caretLeftViewport - containerRect.left + (container?.scrollLeft || 0);
|
||||
setCaretRect({ top, left });
|
||||
} else {
|
||||
setCaretRect({ top: caretTopViewport + window.scrollY, left: caretLeftViewport + window.scrollX });
|
||||
}
|
||||
};
|
||||
|
||||
// Memoize the formatted content to prevent infinite re-rendering
|
||||
const formattedContent = useMemo(() => {
|
||||
if (!draft) return '';
|
||||
return formatDraftContent(draft, citations, researchSources);
|
||||
}, [draft, citations, researchSources]);
|
||||
|
||||
// Keep local textarea in sync with external updates (including confirmed diffs)
|
||||
useEffect(() => {
|
||||
if (draft !== localDraft) {
|
||||
setLocalDraft(draft);
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
// Cleanup debounced saver
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
if (suggestionTimerRef.current) clearTimeout(suggestionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
onMouseUp={assistantOn ? undefined : onTextSelection}
|
||||
style={{
|
||||
padding: '20px',
|
||||
minHeight: '400px',
|
||||
lineHeight: '1.6',
|
||||
position: 'relative',
|
||||
userSelect: 'text',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
{/* Inline Writing Suggestion Card (anchored near caret when editing) */}
|
||||
<WritingAssistantCard
|
||||
assistantOn={assistantOn}
|
||||
waSuggestion={waSuggestion}
|
||||
waError={waError}
|
||||
showContinuePrompt={showContinuePrompt}
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
onDismissSuggestion={onDismissSuggestion}
|
||||
anchor={assistantOn ? caretRect : null}
|
||||
caretIndex={currentCaretIndex}
|
||||
onInsertAtCaret={onInsertWithPreview}
|
||||
onContinueWriting={onContinueWriting}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{isGenerating && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e1f5fe',
|
||||
borderTop: '3px solid #0a66c2',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 16px auto'
|
||||
}} />
|
||||
<div style={{
|
||||
color: '#0277bd',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{loadingMessage || 'Generating LinkedIn content...'}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
maxWidth: '300px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
Crafting professional content tailored to your industry and audience...
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Display */}
|
||||
<div style={{
|
||||
opacity: isGenerating ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease'
|
||||
}}>
|
||||
{draft ? (
|
||||
<div>
|
||||
{assistantOn ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={localDraft}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setLocalDraft(value);
|
||||
|
||||
const caretIndex = e.target.selectionStart ?? value.length;
|
||||
// Debounce suggestion trigger to avoid per-keystroke calls
|
||||
if (suggestionTimerRef.current) clearTimeout(suggestionTimerRef.current);
|
||||
if (onTriggerSuggestion) {
|
||||
suggestionTimerRef.current = setTimeout(() => {
|
||||
onTriggerSuggestion(value, caretIndex);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Update caret rect for popover placement
|
||||
updateCaretRect(e.currentTarget);
|
||||
|
||||
// If user is typing while a suggestion is visible, hide it immediately
|
||||
if (waSuggestion && onDismissSuggestion) {
|
||||
onDismissSuggestion();
|
||||
}
|
||||
|
||||
// Debounce the draft save
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
onDraftChange(value);
|
||||
}, 600);
|
||||
}}
|
||||
onKeyUp={(e) => updateCaretRect(e.currentTarget)}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
outline: 'none',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
background: '#fff',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: formattedContent }} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: '40px'
|
||||
}}>
|
||||
Content will appear here when generated. Use the AI assistant to create your LinkedIn content.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Citation Styling */}
|
||||
<style>{`
|
||||
.liw-cite {
|
||||
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
|
||||
border: 1px solid #64b5f6;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin: 0 2px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
.liw-cite:hover {
|
||||
background: linear-gradient(135deg, #bbdefb, #90caf9);
|
||||
border-color: #42a5f5;
|
||||
box-shadow: 0 4px 8px rgba(25, 118, 210, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.liw-cite:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(25, 118, 210, 0.1);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Text Selection Menu and Fact-Check Components (disabled while editing) */}
|
||||
{!assistantOn && renderSelectionMenu()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentDisplayArea;
|
||||
850
frontend/src/components/TextEditor/ContentPreviewHeader.tsx
Normal file
850
frontend/src/components/TextEditor/ContentPreviewHeader.tsx
Normal file
@@ -0,0 +1,850 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
// Extend HTMLDivElement interface for custom tooltip properties
|
||||
interface ExtendedDivElement extends HTMLDivElement {
|
||||
_researchTooltip?: HTMLDivElement | null;
|
||||
_citationsTooltip?: HTMLDivElement | null;
|
||||
_searchQueriesTooltip?: HTMLDivElement | null;
|
||||
_qualityTooltip?: HTMLDivElement | null;
|
||||
_researchTooltipTimeout?: NodeJS.Timeout | null;
|
||||
_qualityTooltipTimeout?: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
interface ContentPreviewHeaderProps {
|
||||
researchSources?: any[];
|
||||
citations?: any[];
|
||||
searchQueries?: string[];
|
||||
qualityMetrics?: any;
|
||||
draft: string;
|
||||
showPreview: boolean;
|
||||
onPreviewToggle: () => void;
|
||||
assistantOn?: boolean;
|
||||
onAssistantToggle?: (enabled: boolean) => void;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
const ContentPreviewHeader: React.FC<ContentPreviewHeaderProps> = ({
|
||||
researchSources,
|
||||
citations,
|
||||
searchQueries,
|
||||
qualityMetrics,
|
||||
draft,
|
||||
showPreview,
|
||||
onPreviewToggle,
|
||||
assistantOn,
|
||||
onAssistantToggle,
|
||||
topic
|
||||
}) => {
|
||||
const formatPercent = (v?: number) => typeof v === 'number' ? `${Math.round(v * 100)}%` : '—';
|
||||
const getChipColor = (v?: number) => {
|
||||
if (typeof v !== 'number') return '#6b7280';
|
||||
if (v >= 0.8) return '#10b981';
|
||||
if (v >= 0.6) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
};
|
||||
|
||||
// Memoize chips array to prevent infinite re-rendering
|
||||
const chips = useMemo(() => {
|
||||
const chipArray = qualityMetrics ? [
|
||||
{ label: 'Overall', value: qualityMetrics.overall_score },
|
||||
{ label: 'Accuracy', value: qualityMetrics.factual_accuracy },
|
||||
{ label: 'Verification', value: qualityMetrics.source_verification },
|
||||
{ label: 'Coverage', value: qualityMetrics.citation_coverage }
|
||||
] : [];
|
||||
|
||||
console.log('🔍 [ContentPreviewHeader] Chips array created:', {
|
||||
qualityMetrics: qualityMetrics,
|
||||
chips: chipArray,
|
||||
chipsLength: chipArray.length
|
||||
});
|
||||
|
||||
return chipArray;
|
||||
}, [qualityMetrics]);
|
||||
|
||||
// Helper to build descriptive chip tooltip text
|
||||
const chipDescriptions: Record<string, string> = {
|
||||
Overall: 'Overall blends accuracy, verification and coverage into a single reliability score for this draft.',
|
||||
Accuracy: 'Factual Accuracy estimates how likely statements are to be factually correct based on grounding signals.',
|
||||
Verification: 'Source Verification reflects how well claims are linked to credible sources and whether citations match claims.',
|
||||
Coverage: 'Citation Coverage indicates how much of the content is supported with citations. Higher is better.'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#e1f5fe',
|
||||
borderBottom: '1px solid #b3e5fc',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0277bd',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<span>{topic ? `${topic} - LinkedIn Content Preview` : 'LinkedIn Content Preview'}</span>
|
||||
|
||||
{/* Research Chip with Hover Sub-chips */}
|
||||
{((researchSources && researchSources.length > 0) || (citations && citations.length > 0) || (searchQueries && searchQueries.length > 0)) && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Main Research Chip */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
||||
border: '1px solid #0284c7',
|
||||
borderRadius: '999px',
|
||||
padding: '6px 14px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '700',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '0 2px 8px rgba(14, 165, 233, 0.3)',
|
||||
transform: 'translateZ(0)',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
title="Research data available. Hover to see sources, citations, and queries."
|
||||
onMouseEnter={(e) => {
|
||||
// Clear any existing timeout
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._researchTooltipTimeout) {
|
||||
clearTimeout(target._researchTooltipTimeout);
|
||||
target._researchTooltipTimeout = null;
|
||||
}
|
||||
|
||||
// Create and show research sub-chips tooltip
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
background: white;
|
||||
border: 1px solid #cfe9f7;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
padding: 16px;
|
||||
max-width: 400px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
`;
|
||||
|
||||
let subChipsHtml = '<div style="margin-bottom: 12px; font-weight: 600; color: #0a66c2; font-size: 14px;">Research Data</div>';
|
||||
|
||||
// Add Sources sub-chip
|
||||
if (researchSources && researchSources.length > 0) {
|
||||
subChipsHtml += `
|
||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||
onmouseenter="this.style.background='#e0f2fe'; this.style.transform='scale(1.05)'"
|
||||
onmouseleave="this.style.background='#f0f9ff'; this.style.transform='scale(1)'"
|
||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showResearchSourcesModal', { detail: 'sources' }))">
|
||||
<span style="display: inline-block; width: 6px; height: 6px; background: #10b981; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(16, 185, 129, 0.5);"></span>
|
||||
Sources: ${researchSources.length}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add Citations sub-chip
|
||||
if (citations && citations.length > 0) {
|
||||
subChipsHtml += `
|
||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||
onmouseenter="this.style.background='#fde68a'; this.style.transform='scale(1.05)'"
|
||||
onmouseleave="this.style.background='#fef3c7'; this.style.transform='scale(1)'"
|
||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showCitationsModal', { detail: 'citations' }))">
|
||||
<span style="display: inline-block; width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);"></span>
|
||||
Citations: ${citations.length}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add Queries sub-chip
|
||||
if (searchQueries && searchQueries.length > 0) {
|
||||
subChipsHtml += `
|
||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f3e8ff; border: 1px solid #8b5cf6; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||
onmouseenter="this.style.background='#e9d5ff'; this.style.transform='scale(1.05)'"
|
||||
onmouseleave="this.style.background='#f3e8ff'; this.style.transform='scale(1)'"
|
||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showSearchQueriesModal', { detail: 'queries' }))">
|
||||
<span style="display: inline-block; width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(139, 92, 246, 0.5);"></span>
|
||||
Queries: ${searchQueries.length}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
tooltip.innerHTML = subChipsHtml;
|
||||
|
||||
// Add mouse events to tooltip to keep it visible
|
||||
tooltip.addEventListener('mouseenter', () => {
|
||||
if (target._researchTooltipTimeout) {
|
||||
clearTimeout(target._researchTooltipTimeout);
|
||||
target._researchTooltipTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
tooltip.addEventListener('mouseleave', () => {
|
||||
target._researchTooltipTimeout = setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
target._researchTooltip = null;
|
||||
}, 100);
|
||||
});
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
|
||||
target._researchTooltip = tooltip;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._researchTooltip) {
|
||||
// Add delay before hiding to allow moving to tooltip
|
||||
target._researchTooltipTimeout = setTimeout(() => {
|
||||
const tooltip = target._researchTooltip;
|
||||
if (tooltip && tooltip.parentNode) {
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
target._researchTooltip = null;
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// Keep tooltip visible when moving to sub-chips
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._researchTooltip) {
|
||||
const tooltip = target._researchTooltip;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
}
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
// Add hover effect to the chip itself
|
||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(14, 165, 233, 0.4)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
// Remove hover effect
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
||||
}} />
|
||||
Research
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
{/* Quality Metrics Chip */}
|
||||
{chips.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Main Quality Metrics Chip */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
border: '1px solid #047857',
|
||||
borderRadius: '999px',
|
||||
padding: '6px 14px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '700',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||
transform: 'translateZ(0)',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
title="Quality metrics available. Hover to see detailed progress bars and explanations."
|
||||
onMouseEnter={(e) => {
|
||||
// Clear any existing timeout
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._qualityTooltipTimeout) {
|
||||
clearTimeout(target._qualityTooltipTimeout);
|
||||
target._qualityTooltipTimeout = null;
|
||||
}
|
||||
|
||||
// Create and show quality metrics tooltip with circular progress bars
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
background: white;
|
||||
border: 1px solid #d1fae5;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
`;
|
||||
|
||||
// Create circular progress bars for each metric
|
||||
const createCircularProgress = (label: string, value: number, description: string) => {
|
||||
const percentage = Math.round(value * 100);
|
||||
const color = getChipColor(value);
|
||||
const circumference = 2 * Math.PI * 45; // radius = 45
|
||||
const strokeDasharray = circumference;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return `
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 12px; background: #f8fafc; border-radius: 12px; border-left: 4px solid ${color};">
|
||||
<div style="position: relative; width: 60px; height: 60px;">
|
||||
<svg width="60" height="60" style="transform: rotate(-90deg);">
|
||||
<circle cx="30" cy="30" r="45" stroke="#e5e7eb" stroke-width="6" fill="none"/>
|
||||
<circle cx="30" cy="30" r="45" stroke="${color}" stroke-width="6" fill="none"
|
||||
stroke-dasharray="${strokeDasharray}" stroke-dashoffset="${strokeDashoffset}"
|
||||
style="transition: stroke-dashoffset 0.5s ease;"/>
|
||||
</svg>
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: 700; font-size: 14px; color: ${color};">
|
||||
${percentage}%
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 700; color: #1f2937; margin-bottom: 4px; font-size: 14px;">${label}</div>
|
||||
<div style="color: #6b7280; line-height: 1.4; font-size: 11px;">${description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
let progressBarsHtml = '<div style="margin-bottom: 16px; font-weight: 700; color: #059669; font-size: 16px; text-align: center;">Quality Metrics</div>';
|
||||
|
||||
chips.forEach(chip => {
|
||||
progressBarsHtml += createCircularProgress(
|
||||
chip.label,
|
||||
chip.value || 0,
|
||||
chipDescriptions[chip.label] || ''
|
||||
);
|
||||
});
|
||||
|
||||
tooltip.innerHTML = progressBarsHtml;
|
||||
|
||||
// Add mouse events to tooltip to keep it visible
|
||||
tooltip.addEventListener('mouseenter', () => {
|
||||
if (target._qualityTooltipTimeout) {
|
||||
clearTimeout(target._qualityTooltipTimeout);
|
||||
target._qualityTooltipTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
tooltip.addEventListener('mouseleave', () => {
|
||||
target._qualityTooltipTimeout = setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
target._qualityTooltip = null;
|
||||
}, 100);
|
||||
});
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
|
||||
target._qualityTooltip = tooltip;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._qualityTooltip) {
|
||||
// Add delay before hiding to allow moving to tooltip
|
||||
target._qualityTooltipTimeout = setTimeout(() => {
|
||||
const tooltip = target._qualityTooltip;
|
||||
if (tooltip && tooltip.parentNode) {
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transform = 'translateY(-8px)';
|
||||
setTimeout(() => {
|
||||
if (tooltip.parentNode) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
target._qualityTooltip = null;
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// Keep tooltip visible when moving to progress bars
|
||||
const target = e.currentTarget as ExtendedDivElement;
|
||||
if (target._qualityTooltip) {
|
||||
const tooltip = target._qualityTooltip;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||
}
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
// Add hover effect to the chip itself
|
||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
// Remove hover effect
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
||||
}} />
|
||||
Quality Metrics
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||
{draft.split(/\s+/).length} words • {Math.ceil(draft.split(/\s+/).length / 200)} min read
|
||||
</span>
|
||||
{/* Assistive Writing toggle */}
|
||||
{onAssistantToggle && (
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
||||
title="Assistive Writing: Get real-time AI-powered writing suggestions as you type. Uses Exa.ai for web research and Gemini for intelligent content generation. Automatically enables editing mode to allow typing and content modification."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assistantOn || false}
|
||||
onChange={(e) => onAssistantToggle(e.target.checked)}
|
||||
/>
|
||||
Assistive Writing
|
||||
</label>
|
||||
)}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
||||
title="Toggle preview visibility"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!showPreview}
|
||||
onChange={() => onPreviewToggle()}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
Hide Preview
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Research Sources Modal Component
|
||||
const ResearchSourcesModal: React.FC<{ sources: any[]; isOpen: boolean; onClose: () => void }> = ({ sources, isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000000
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||
Research Sources ({sources.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{sources && Array.isArray(sources) ? sources.map((source, idx) => (
|
||||
<div key={idx} style={{
|
||||
marginBottom: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
borderLeft: '4px solid #0a66c2'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#0a66c2' }}>
|
||||
{source.title || 'Untitled Source'}
|
||||
</div>
|
||||
<div style={{ color: '#666', marginBottom: '12px', lineHeight: '1.5' }}>
|
||||
{source.content || 'No description available'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{source.relevance_score && (
|
||||
<span style={{
|
||||
backgroundColor: '#eef6ff',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#0a66c2'
|
||||
}}>
|
||||
Relevance: {Math.round(source.relevance_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{source.credibility_score && (
|
||||
<span style={{
|
||||
backgroundColor: '#eef6ff',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#0a66c2'
|
||||
}}>
|
||||
Credibility: {Math.round(source.credibility_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
{source.domain_authority && (
|
||||
<span style={{
|
||||
backgroundColor: '#eef6ff',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#0a66c2'
|
||||
}}>
|
||||
Authority: {Math.round(source.domain_authority * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
No research sources available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Citations Modal Component
|
||||
const CitationsModal: React.FC<{ citations: any[]; isOpen: boolean; onClose: () => void }> = ({ citations, isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000000
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||
Citations ({citations.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{citations && Array.isArray(citations) ? citations.map((citation, idx) => (
|
||||
<div key={idx} style={{
|
||||
marginBottom: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #f59e0b'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', color: '#0a66c2', marginBottom: '4px' }}>
|
||||
Citation {idx + 1}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
|
||||
Type: {citation.type || 'inline'}
|
||||
</div>
|
||||
{citation.reference && (
|
||||
<div style={{ color: '#666', fontSize: '12px' }}>
|
||||
Reference: {citation.reference}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
No citations available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Search Queries Modal Component
|
||||
const SearchQueriesModal: React.FC<{ queries: string[]; isOpen: boolean; onClose: () => void }> = ({ queries, isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000000
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||
}} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||
Search Queries Used ({queries.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{queries && Array.isArray(queries) ? queries.map((query, idx) => (
|
||||
<div key={idx} style={{
|
||||
marginBottom: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #8b5cf6'
|
||||
}}>
|
||||
<div style={{ fontWeight: '600', color: '#7c3aed', marginBottom: '6px' }}>
|
||||
Query {idx + 1}
|
||||
</div>
|
||||
<div style={{ color: '#374151', fontSize: '13px', lineHeight: '1.4' }}>
|
||||
{query}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
No search queries available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced ContentPreviewHeader with Modal State
|
||||
const ContentPreviewHeaderWithModals: React.FC<ContentPreviewHeaderProps> = (props) => {
|
||||
const [showResearchSourcesModal, setShowResearchSourcesModal] = useState(false);
|
||||
const [showCitationsModal, setShowCitationsModal] = useState(false);
|
||||
const [showSearchQueriesModal, setShowSearchQueriesModal] = useState(false);
|
||||
const [modalData, setModalData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowResearchSourcesModal = (event: CustomEvent) => {
|
||||
try {
|
||||
const dataType = event.detail;
|
||||
let data: any[] = [];
|
||||
|
||||
if (dataType === 'sources') {
|
||||
data = props.researchSources || [];
|
||||
}
|
||||
|
||||
setModalData(Array.isArray(data) ? data : []);
|
||||
setShowResearchSourcesModal(true);
|
||||
} catch (error) {
|
||||
console.error('Error handling research sources modal:', error);
|
||||
setModalData([]);
|
||||
setShowResearchSourcesModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowCitationsModal = (event: CustomEvent) => {
|
||||
try {
|
||||
const dataType = event.detail;
|
||||
let data: any[] = [];
|
||||
|
||||
if (dataType === 'citations') {
|
||||
data = props.citations || [];
|
||||
}
|
||||
|
||||
setModalData(Array.isArray(data) ? data : []);
|
||||
setShowCitationsModal(true);
|
||||
} catch (error) {
|
||||
console.error('Error handling citations modal:', error);
|
||||
setModalData([]);
|
||||
setShowCitationsModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowSearchQueriesModal = (event: CustomEvent) => {
|
||||
try {
|
||||
const dataType = event.detail;
|
||||
let data: any[] = [];
|
||||
|
||||
if (dataType === 'queries') {
|
||||
data = props.searchQueries || [];
|
||||
}
|
||||
|
||||
setModalData(Array.isArray(data) ? data : []);
|
||||
setShowSearchQueriesModal(true);
|
||||
} catch (error) {
|
||||
console.error('Error handling search queries modal:', error);
|
||||
setModalData([]);
|
||||
setShowSearchQueriesModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
||||
window.addEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
||||
window.addEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
||||
window.removeEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
||||
window.removeEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentPreviewHeader {...props} />
|
||||
<ResearchSourcesModal
|
||||
sources={modalData || []}
|
||||
isOpen={showResearchSourcesModal}
|
||||
onClose={() => setShowResearchSourcesModal(false)}
|
||||
/>
|
||||
<CitationsModal
|
||||
citations={modalData || []}
|
||||
isOpen={showCitationsModal}
|
||||
onClose={() => setShowCitationsModal(false)}
|
||||
/>
|
||||
<SearchQueriesModal
|
||||
queries={modalData || []}
|
||||
isOpen={showSearchQueriesModal}
|
||||
onClose={() => setShowSearchQueriesModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPreviewHeader;
|
||||
export { ContentPreviewHeader, ContentPreviewHeaderWithModals };
|
||||
85
frontend/src/components/TextEditor/DiffPreviewModal.tsx
Normal file
85
frontend/src/components/TextEditor/DiffPreviewModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { diffMarkup } from '../LinkedInWriter/utils/contentFormatters';
|
||||
|
||||
interface DiffPreviewModalProps {
|
||||
isPreviewing: boolean;
|
||||
pendingEdit: { src: string; target: string } | null;
|
||||
livePreviewHtml: string;
|
||||
onConfirmChanges: () => void;
|
||||
onDiscardChanges: () => void;
|
||||
}
|
||||
|
||||
const DiffPreviewModal: React.FC<DiffPreviewModalProps> = ({
|
||||
isPreviewing,
|
||||
pendingEdit,
|
||||
livePreviewHtml,
|
||||
onConfirmChanges,
|
||||
onDiscardChanges
|
||||
}) => {
|
||||
if (!isPreviewing || !pendingEdit) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
margin: '24px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<strong style={{ color: '#0a66c2' }}>Preview Changes</strong>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={onConfirmChanges}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#0a66c2',
|
||||
color: '#fff',
|
||||
border: '1px solid #0a66c2',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
Confirm Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={onDiscardChanges}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#fff',
|
||||
color: '#444',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 16 }}>
|
||||
<div
|
||||
style={{ fontFamily: 'inherit', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml || diffMarkup(pendingEdit.src, pendingEdit.target) }}
|
||||
/>
|
||||
<style>{`
|
||||
.liw-add { background: rgba(46, 204, 113, 0.18); font-style: normal; }
|
||||
.liw-del { color: #c0392b; text-decoration: line-through; opacity: 0.8; }
|
||||
.liw-more { color: #999; }
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreviewModal;
|
||||
76
frontend/src/components/TextEditor/QuickEditToolbar.tsx
Normal file
76
frontend/src/components/TextEditor/QuickEditToolbar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
interface QuickEditToolbarProps {
|
||||
draft: string;
|
||||
isPreviewing: boolean;
|
||||
}
|
||||
|
||||
const QuickEditToolbar: React.FC<QuickEditToolbarProps> = ({ draft, isPreviewing }) => {
|
||||
if (!draft || isPreviewing) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#666', alignSelf: 'center' }}>
|
||||
Quick edits (preview):
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const lines = draft.split('\n');
|
||||
if (lines.length > 0) {
|
||||
const first = lines[0].trim();
|
||||
lines[0] = first.replace(/^(.*?)([\.!?])?$/, '👉 $1$2');
|
||||
}
|
||||
const target = lines.join('\n');
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
|
||||
}}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
|
||||
>
|
||||
Tighten Hook
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = draft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
|
||||
}}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
|
||||
>
|
||||
Add CTA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = draft.length > 200 ? draft.substring(0, 200) + '...' : draft;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
|
||||
}}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
|
||||
>
|
||||
Shorten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = draft + '\n\nThis approach has shown strong results. The key is to maintain consistency while adapting to changing conditions.';
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
|
||||
}}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
|
||||
>
|
||||
Lengthen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = `[Professionalized]` + '\n\n' + draft;
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', { detail: { target } }));
|
||||
}}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, background: '#fff', cursor: 'pointer' }}
|
||||
>
|
||||
Professionalize
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickEditToolbar;
|
||||
588
frontend/src/components/TextEditor/TextSelectionHandler.tsx
Normal file
588
frontend/src/components/TextEditor/TextSelectionHandler.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../services/hallucinationDetectorService';
|
||||
import FactCheckResults from '../LinkedInWriter/components/FactCheckResults';
|
||||
|
||||
interface TextSelectionHandlerProps {
|
||||
contentRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const useTextSelectionHandler = (contentRef: React.RefObject<HTMLDivElement>) => {
|
||||
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
|
||||
const [isFactChecking, setIsFactChecking] = useState(false);
|
||||
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
|
||||
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fact-checking functionality
|
||||
const handleCheckFacts = async (text: string) => {
|
||||
console.log('🔍 [TextSelectionHandler] handleCheckFacts called with text:', text);
|
||||
if (!text.trim()) {
|
||||
console.log('🔍 [TextSelectionHandler] No text to check, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 [TextSelectionHandler] Starting fact check for:', text.trim());
|
||||
setIsFactChecking(true);
|
||||
setSelectionMenu(null);
|
||||
|
||||
// Progress tracking
|
||||
const progressSteps = [
|
||||
{ step: "Extracting verifiable claims...", progress: 20 },
|
||||
{ step: "Searching for evidence...", progress: 40 },
|
||||
{ step: "Analyzing claims against sources...", progress: 70 },
|
||||
{ step: "Generating final assessment...", progress: 90 },
|
||||
{ step: "Completing fact-check...", progress: 100 }
|
||||
];
|
||||
|
||||
let currentStepIndex = 0;
|
||||
|
||||
// Start progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
if (currentStepIndex < progressSteps.length) {
|
||||
setFactCheckProgress(progressSteps[currentStepIndex]);
|
||||
currentStepIndex++;
|
||||
}
|
||||
}, 2000); // Update every 2 seconds
|
||||
|
||||
// Set a timeout for the fact check (30 seconds)
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('🔍 [TextSelectionHandler] Fact check timeout reached');
|
||||
clearInterval(progressInterval);
|
||||
setFactCheckProgress(null);
|
||||
setIsFactChecking(false);
|
||||
setFactCheckResults({
|
||||
success: false,
|
||||
claims: [],
|
||||
overall_confidence: 0,
|
||||
total_claims: 0,
|
||||
supported_claims: 0,
|
||||
refuted_claims: 0,
|
||||
insufficient_claims: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
|
||||
});
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
console.log('🔍 [TextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
|
||||
const results = await hallucinationDetectorService.detectHallucinations({
|
||||
text: text.trim(),
|
||||
include_sources: true,
|
||||
max_claims: 10
|
||||
});
|
||||
|
||||
console.log('🔍 [TextSelectionHandler] Fact check results received:', results);
|
||||
console.log('🔍 [TextSelectionHandler] Results success:', results.success);
|
||||
console.log('🔍 [TextSelectionHandler] Results claims count:', results.claims?.length || 0);
|
||||
console.log('🔍 [TextSelectionHandler] Setting factCheckResults state...');
|
||||
setFactCheckResults(results);
|
||||
console.log('🔍 [TextSelectionHandler] factCheckResults state set');
|
||||
} catch (error) {
|
||||
console.error('🔍 [TextSelectionHandler] Error checking facts:', error);
|
||||
setFactCheckResults({
|
||||
success: false,
|
||||
claims: [],
|
||||
overall_confidence: 0,
|
||||
total_claims: 0,
|
||||
supported_claims: 0,
|
||||
refuted_claims: 0,
|
||||
insufficient_claims: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
} finally {
|
||||
console.log('🔍 [TextSelectionHandler] Fact check completed, setting isFactChecking to false');
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
setFactCheckProgress(null);
|
||||
setIsFactChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseFactCheckResults = () => {
|
||||
setFactCheckResults(null);
|
||||
};
|
||||
|
||||
// Quick edit functionality for selected text
|
||||
const handleQuickEdit = (editType: string, selectedText: string) => {
|
||||
console.log('🔍 [TextSelectionHandler] handleQuickEdit called:', editType, selectedText);
|
||||
|
||||
let editedText = selectedText;
|
||||
|
||||
switch (editType) {
|
||||
case 'tighten':
|
||||
// Add hook emoji to the beginning
|
||||
editedText = selectedText.replace(/^(.*?)([\.!?])?$/, '👉 $1$2');
|
||||
break;
|
||||
case 'add-cta':
|
||||
// Add call-to-action
|
||||
editedText = selectedText + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
|
||||
break;
|
||||
case 'shorten':
|
||||
// Truncate if longer than 100 characters
|
||||
editedText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText;
|
||||
break;
|
||||
case 'lengthen':
|
||||
// Add more content
|
||||
editedText = selectedText + '\n\nThis approach has shown strong results. The key is to maintain consistency while adapting to changing conditions.';
|
||||
break;
|
||||
case 'professionalize':
|
||||
// Add professional prefix
|
||||
editedText = '[Professionalized]\n\n' + selectedText;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch event to replace the selected text
|
||||
window.dispatchEvent(new CustomEvent('linkedinwriter:replaceSelectedText', {
|
||||
detail: {
|
||||
originalText: selectedText,
|
||||
editedText: editedText,
|
||||
editType: editType
|
||||
}
|
||||
}));
|
||||
|
||||
// Close the selection menu
|
||||
setSelectionMenu(null);
|
||||
};
|
||||
|
||||
// Cleanup progress and timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFactCheckProgress(null);
|
||||
if (selectionTimeoutRef.current) {
|
||||
clearTimeout(selectionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debug: Log selection menu changes
|
||||
useEffect(() => {
|
||||
console.log('🔍 [TextSelectionHandler] Selection menu state changed:', selectionMenu);
|
||||
}, [selectionMenu]);
|
||||
|
||||
// Text selection handler with debouncing
|
||||
const handleTextSelection = () => {
|
||||
console.log('🔍 [TextSelectionHandler] handleTextSelection called');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (selectionTimeoutRef.current) {
|
||||
clearTimeout(selectionTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the selection handling
|
||||
selectionTimeoutRef.current = setTimeout(() => {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
console.log('🔍 [TextSelectionHandler] Selection object (debounced):', sel);
|
||||
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
console.log('🔍 [TextSelectionHandler] No selection or range count is 0');
|
||||
setSelectionMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = (sel.toString() || '').trim();
|
||||
console.log('🔍 [TextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
|
||||
|
||||
if (!text || text.length < 10) {
|
||||
console.log('🔍 [TextSelectionHandler] Text too short or empty, hiding menu');
|
||||
setSelectionMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const container = contentRef.current?.getBoundingClientRect();
|
||||
|
||||
console.log('🔍 [TextSelectionHandler] Range rect:', rect, 'Container rect:', container);
|
||||
if (!container) {
|
||||
console.log('🔍 [TextSelectionHandler] No container rect, hiding menu');
|
||||
setSelectionMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const x = Math.max(8, rect.left - container.left + (rect.width / 2));
|
||||
const y = Math.max(8, rect.top - container.top);
|
||||
|
||||
const menuPosition = { x, y, text };
|
||||
console.log('🔍 [TextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
|
||||
setSelectionMenu(menuPosition);
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔍 [TextSelectionHandler] Error handling text selection:', error);
|
||||
setSelectionMenu(null);
|
||||
}
|
||||
}, 150); // 150ms debounce
|
||||
};
|
||||
|
||||
return {
|
||||
selectionMenu,
|
||||
setSelectionMenu,
|
||||
factCheckResults,
|
||||
isFactChecking,
|
||||
factCheckProgress,
|
||||
handleTextSelection,
|
||||
handleCheckFacts,
|
||||
handleCloseFactCheckResults,
|
||||
// Render the selection menu and fact-check components
|
||||
renderSelectionMenu: () => (
|
||||
<>
|
||||
{/* Text Selection Menu */}
|
||||
{selectionMenu && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionHandler] Selection menu clicked!', e.target);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: selectionMenu.y - 40,
|
||||
left: selectionMenu.x - 200,
|
||||
background: 'rgba(10, 102, 194, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 10px 24px rgba(0, 0, 0, 0.35)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10000,
|
||||
minWidth: '200px'
|
||||
}}
|
||||
>
|
||||
{/* Fact Check Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionHandler] Check Facts button clicked!', selectionMenu.text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCheckFacts(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: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: isFactChecking ? 'not-allowed' : 'pointer',
|
||||
opacity: isFactChecking ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
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: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderTop: '2px solid white',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔍 Check Facts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Quick Edit Options */}
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
paddingTop: '8px',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
<div style={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '6px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Quick Edit:
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('tighten', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
Tighten
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('add-cta', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
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 CTA
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('shorten', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
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();
|
||||
handleQuickEdit('lengthen', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
Lengthen
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleQuickEdit('professionalize', selectionMenu.text);
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
gridColumn: '1 / -1'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||
}}
|
||||
>
|
||||
Professionalize
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={() => setSelectionMenu(null)}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
width: '100%',
|
||||
marginTop: '4px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.8)';
|
||||
}}
|
||||
>
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Progress Modal */}
|
||||
{isFactChecking && factCheckProgress && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
maxWidth: '400px',
|
||||
width: '90%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #e3f2fd',
|
||||
borderTop: '4px solid #1976d2',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 24px'
|
||||
}}
|
||||
/>
|
||||
<h3 style={{ margin: '0 0 16px', color: '#1976d2', fontSize: '18px', fontWeight: '600' }}>
|
||||
Fact-Checking in Progress
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 24px', color: '#666', fontSize: '14px', lineHeight: '1.5' }}>
|
||||
{factCheckProgress.step}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${factCheckProgress.progress}%`,
|
||||
height: '100%',
|
||||
backgroundColor: '#1976d2',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.5s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ margin: '0', color: '#999', fontSize: '12px' }}>
|
||||
This may take 10-15 seconds...
|
||||
</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fact Check Results Modal */}
|
||||
{factCheckResults && (
|
||||
<>
|
||||
{console.log('🔍 [TextSelectionHandler] Rendering FactCheckResults with:', factCheckResults)}
|
||||
<FactCheckResults
|
||||
results={factCheckResults}
|
||||
onClose={handleCloseFactCheckResults}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
export default useTextSelectionHandler;
|
||||
187
frontend/src/components/TextEditor/WritingAssistantCard.tsx
Normal file
187
frontend/src/components/TextEditor/WritingAssistantCard.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React from 'react';
|
||||
import { WASuggestion } from '../../services/writingAssistantService';
|
||||
|
||||
interface WritingAssistantCardProps {
|
||||
assistantOn: boolean;
|
||||
waSuggestion: WASuggestion | null;
|
||||
waError?: string | null;
|
||||
showContinuePrompt?: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onDismissSuggestion: () => void;
|
||||
anchor?: { top: number; left: number } | null;
|
||||
caretIndex?: number;
|
||||
onInsertAtCaret?: (text: string, caretIndex: number) => void;
|
||||
onContinueWriting?: () => void;
|
||||
}
|
||||
|
||||
const WritingAssistantCard: React.FC<WritingAssistantCardProps> = ({
|
||||
assistantOn,
|
||||
waSuggestion,
|
||||
waError,
|
||||
showContinuePrompt,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onDismissSuggestion,
|
||||
anchor,
|
||||
caretIndex,
|
||||
onInsertAtCaret,
|
||||
onContinueWriting
|
||||
}) => {
|
||||
if (!assistantOn || (!waSuggestion && !waError && !showContinuePrompt)) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: anchor ? 'absolute' : 'sticky',
|
||||
top: anchor ? `${anchor.top}px` : 0,
|
||||
left: anchor ? `${anchor.left}px` : undefined,
|
||||
width: anchor ? 'auto' : '100%',
|
||||
minWidth: anchor ? '320px' : 'auto',
|
||||
maxWidth: anchor ? '600px' : '100%',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: anchor ? 0 : 12,
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.12)',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}>
|
||||
{waError ? (
|
||||
// Error state
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong style={{ color: '#d32f2f' }}>⚠️ Assistive Writing Error</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#d32f2f', marginBottom: 8 }}>
|
||||
{waError}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onDismissSuggestion}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d32f2f',
|
||||
background: '#d32f2f',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
minWidth: '80px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : showContinuePrompt ? (
|
||||
// Continue CTA state
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong style={{ color: '#0a66c2' }}>Assistive Writing</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#333', marginBottom: 8 }}>
|
||||
ALwrity can contextually continue writing. Click Continue writing.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => onContinueWriting && onContinueWriting()}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #0a66c2',
|
||||
background: '#0a66c2',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
minWidth: '120px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
Continue writing
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissSuggestion}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ddd',
|
||||
background: '#fff',
|
||||
color: '#555',
|
||||
fontSize: 12,
|
||||
minWidth: '80px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : waSuggestion ? (
|
||||
// Suggestion state
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong style={{ color: '#0a66c2' }}>Assistive Writing Suggestion</strong>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>Confidence: {Math.round((waSuggestion.confidence || 0) * 100)}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#333', marginBottom: 8 }}>
|
||||
{waSuggestion.text}
|
||||
</div>
|
||||
{waSuggestion.sources?.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: '#666', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{waSuggestion.sources.slice(0, 2).map((s, i) => (
|
||||
<a key={i} href={s.url} target="_blank" rel="noreferrer" style={{ color: '#0a66c2', textDecoration: 'none' }}>{s.title}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!waSuggestion) return;
|
||||
|
||||
// If we have caret position and insert function, insert at caret
|
||||
if (typeof caretIndex === 'number' && onInsertAtCaret) {
|
||||
onInsertAtCaret(waSuggestion.text, caretIndex);
|
||||
} else {
|
||||
// Fallback to appending at end
|
||||
const newDraft = draft.endsWith(' ') ? draft + waSuggestion.text : draft + ' ' + waSuggestion.text;
|
||||
onDraftChange(newDraft);
|
||||
}
|
||||
onDismissSuggestion();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #0a66c2',
|
||||
background: '#0a66c2',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
minWidth: '80px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissSuggestion}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ddd',
|
||||
background: '#fff',
|
||||
color: '#555',
|
||||
fontSize: 12,
|
||||
minWidth: '80px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WritingAssistantCard;
|
||||
8
frontend/src/components/TextEditor/index.ts
Normal file
8
frontend/src/components/TextEditor/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as CitationHoverHandler } from './CitationHoverHandler';
|
||||
export { default as useTextSelectionHandler } from './TextSelectionHandler';
|
||||
export { default as QuickEditToolbar } from './QuickEditToolbar';
|
||||
export { default as DiffPreviewModal } from './DiffPreviewModal';
|
||||
export { default as ContentPreviewHeader } from './ContentPreviewHeader';
|
||||
export { ContentPreviewHeaderWithModals } from './ContentPreviewHeader';
|
||||
export { default as WritingAssistantCard } from './WritingAssistantCard';
|
||||
export { default as ContentDisplayArea } from './ContentDisplayArea';
|
||||
Reference in New Issue
Block a user