GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Sparkles, Save, Edit3, Loader2, Info } from 'lucide-react';
import { generateProjectSection, updateProjectSection, reviewProjectSection } from '../../api/client';
import toast from 'react-hot-toast';
import CriticFeedbackPanel from './CriticFeedbackPanel';
import SectionVersionsViewer from './SectionVersionsViewer';
import { History } from 'lucide-react';
interface SectionEditorProps {
projectId: string;
sectionId: string; // The type/id (e.g., 'description', 'budget')
sectionTitle: string;
initialContent?: string;
dbSectionId?: string; // If section is already in DB
onSectionUpdated: () => void;
}
const SectionEditor: React.FC<SectionEditorProps> = ({ projectId, sectionId, sectionTitle, initialContent = '', dbSectionId, onSectionUpdated }) => {
const [content, setContent] = useState(initialContent);
const [isGenerating, setIsGenerating] = useState(false);
const [isReviewing, setIsReviewing] = useState(false);
// Critic state
const [criticResult, setCriticResult] = useState<{isApproved: boolean, feedback: string, severity: 'low'|'medium'|'high'} | null>(null);
// Auto-save state
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showVersionsModal, setShowVersionsModal] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const handleInsertSuggestion = (e: any) => {
if (!e.detail || !e.detail.text) return;
const textToInsert = e.detail.text;
const mode = e.detail.mode || 'insert';
setContent(prev => {
const textarea = textAreaRef.current;
if (!textarea) return prev + '\n\n' + textToInsert;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (mode === 'replace') {
if (start !== end) {
// Replace selected text
return prev.substring(0, start) + textToInsert + prev.substring(end);
} else {
// Replace entire text
return textToInsert;
}
} else {
// Insert at cursor
return prev.substring(0, start) + textToInsert + prev.substring(start);
}
});
// Note: we might want to focus the textarea after insertion but React state update is async.
};
window.addEventListener('insert-suggestion', handleInsertSuggestion);
return () => window.removeEventListener('insert-suggestion', handleInsertSuggestion);
}, []);
// Listen for external updates (e.g. from AutoFix or Chat Replace)
useEffect(() => {
const handleExternalUpdate = (e: any) => {
if (e.detail && e.detail.sectionType === sectionId && e.detail.content !== undefined) {
setContent(e.detail.content);
setCriticResult(null);
setAutoSaveStatus('idle');
}
};
window.addEventListener('external-section-update', handleExternalUpdate);
return () => window.removeEventListener('external-section-update', handleExternalUpdate);
}, [sectionId]);
const prevInitialContentRef = useRef(initialContent);
useEffect(() => {
if (initialContent !== prevInitialContentRef.current) {
// Update content if it hasn't been locally modified by the user
if (content === prevInitialContentRef.current || content === '') {
setContent(initialContent);
setCriticResult(null);
setAutoSaveStatus('idle');
}
prevInitialContentRef.current = initialContent;
}
}, [initialContent, content]);
useEffect(() => {
setContent(initialContent);
prevInitialContentRef.current = initialContent;
setCriticResult(null); // Reset when section changes
setAutoSaveStatus('idle');
}, [sectionId]); // Only refresh content when changing active section tab to avoid cursor jumping
useEffect(() => {
// Prevent auto-saving if content hasn't changed or it's not initialized in DB yet
if (!dbSectionId || content === initialContent) return;
setAutoSaveStatus('saving');
const timeoutId = setTimeout(async () => {
try {
await updateProjectSection(projectId, dbSectionId, content);
setAutoSaveStatus('saved');
// We're omitting onSectionUpdated() here so it doesn't re-render entire List repeatedly
} catch (error) {
toast.error("Błąd auto-zapisu");
setAutoSaveStatus('idle');
}
}, 1500);
return () => clearTimeout(timeoutId);
}, [content, projectId, dbSectionId, initialContent]);
const handleGenerate = async (additionalContext?: string) => {
const loadingToast = toast.loading("Trwa automatyczna optymalizacja sekcji… Prosimy o chwilę cierpliwości.");
try {
setIsGenerating(true);
const data = await generateProjectSection(projectId, sectionId, additionalContext || "Odpowiednie technologie i plan badawczy");
setContent(data.content || '');
toast.success("Optymalizacja zakończona sukcesem.", { id: loadingToast });
setCriticResult(null);
onSectionUpdated();
} catch (error) {
toast.error("Przepraszamy, napotkano problem techniczny. Spróbuj wygenerować sekcję ponownie za chwilę.", { id: loadingToast, duration: 5000 });
} finally {
setIsGenerating(false);
}
};
const handleReview = async () => {
if (!content.trim()) {
toast.error("Brak treści do recenzji");
return;
}
const loadingToast = toast.loading("Critic weryfikuje treść...");
try {
setIsReviewing(true);
const data = await reviewProjectSection(projectId, sectionId, content);
setCriticResult({
isApproved: data.is_approved,
feedback: data.feedback,
severity: data.severity as 'low'|'medium'|'high'
});
if(data.is_approved) {
toast.success("Sekcja zweryfikowana pozytywnie!", { id: loadingToast });
onSectionUpdated(); // Może backend zmienił status na approved, odswiezmy liste
} else {
toast.error("Znaleziono elementy do poprawy.", { id: loadingToast });
}
} catch (error) {
toast.error("Przepraszamy, usługa recenzji jest w tym momencie niedostępna. Spróbuj ponownie później.", { id: loadingToast });
} finally {
setIsReviewing(false);
}
};
return (
<motion.div className="section-editor-grid" initial={{opacity: 0, y: 15}} animate={{opacity: 1, y: 0}} style={{ display: 'grid', gridTemplateColumns: criticResult ? '1fr 350px' : '1fr', gap: '1.5rem', transition: '0.3s all' }}>
{/* TEXT EDITOR AREA */}
<div className="glass-card" style={{ padding: '0', display: 'flex', flexDirection: 'column' }}>
<div style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', padding: '1rem 1.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(0,0,0,0.2)' }}>
<div style={{ fontWeight: 800, color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Edit3 size={18} color="var(--accent-blue)"/> {sectionTitle}
{(sectionId.toLowerCase().includes('budget') || sectionTitle.toLowerCase().includes('budżet')) && (
<div className="tooltip-container" style={{ position: 'relative', display: 'flex', alignItems: 'center', cursor: 'help', color: 'var(--text-muted)' }}>
<Info size={16} />
<div className="tooltip-content" style={{ position: 'absolute', bottom: '120%', left: '50%', transform: 'translateX(-50%)', width: '280px', background: 'var(--bg-elevated)', padding: '1rem', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 10px 25px rgba(0,0,0,0.5)', opacity: 0, visibility: 'hidden', transition: '0.2s', zIndex: 10 }}>
<strong style={{ display: 'block', marginBottom: '0.4rem', color: 'var(--accent-green)' }}>Wskazówka: Koszty Kwalifikowalne</strong>
<p style={{ fontSize: '0.8rem', margin: 0, lineHeight: 1.5, color: 'var(--text-secondary)' }}>Maksymalny limit na wydatki promocyjne i cateringowe zależy od konkursu (np. przy Ścieżce SMART: "Catering dla zespołu – tak, ale max 2% wartości kosztów ogólnych").</p>
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.8rem', alignItems: 'center' }}>
{dbSectionId && autoSaveStatus !== 'idle' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: autoSaveStatus === 'saving' ? 'var(--text-muted)' : 'var(--accent-green)', fontSize: '0.85rem', marginRight: '0.5rem', transition: '0.3s' }}>
{autoSaveStatus === 'saving' ? <Loader2 size={14} className="spin" /> : <Save size={14}/>}
{autoSaveStatus === 'saving' ? 'Zapisywanie...' : 'Zapisano sekcję'}
</div>
)}
<button onClick={() => setShowVersionsModal(true)} disabled={!dbSectionId} className="btn hover-lift" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', background: 'rgba(59, 130, 246, 0.1)', color: '#3b82f6', border: '1px solid rgba(59, 130, 246, 0.3)', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<History size={14}/> Wersje
</button>
<button onClick={handleReview} disabled={isReviewing || !content} className="btn hover-lift" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', background: 'rgba(245, 158, 11, 0.15)', color: '#FCD34D', border: '1px solid rgba(245, 158, 11, 0.3)', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{isReviewing ? <Loader2 size={14} className="spin" /> : <AlertTriangle size={14}/>}
Recenzja Critic
</button>
<button onClick={() => handleGenerate()} disabled={isGenerating} className="btn hover-lift" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', background: 'linear-gradient(90deg, var(--accent-green), #3b82f6)', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{isGenerating ? <Loader2 size={14} className="spin" /> : <Sparkles size={14}/>}
Generuj z AI
</button>
</div>
</div>
{isGenerating ? (
<div style={{ minHeight: '500px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: '1rem' }}>
<Loader2 size={40} className="spin" color="var(--accent-green)"/>
<p style={{ color: 'var(--text-muted)' }}>Trwa automatyczna optymalizacja sekcji… Prosimy o chwilę cierpliwości.</p>
</div>
) : (
<textarea
ref={textAreaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Kliknij 'Generuj z AI' w prawym górnym rogu aby napełnić tę sekcję precyzyjnymi danymi ze sprofilowanego RAG."
style={{ width: '100%', minHeight: '500px', background: 'transparent', border: 'none', color: '#fff', padding: '1.5rem', fontSize: '1rem', lineHeight: 1.6, resize: 'vertical', outline: 'none' }}
/>
)}
</div>
{/* CRITIC FEEDBACK AREA */}
{criticResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<CriticFeedbackPanel
isApproved={criticResult.isApproved}
feedback={criticResult.feedback}
severity={criticResult.severity}
onApplyFixes={() => handleGenerate(`Zastosuj poprawki krytyka: ${criticResult.feedback}`)}
/>
</div>
)}
<div style={{ marginTop: '0.5rem', textAlign: 'center', fontSize: '0.75rem', color: 'var(--text-muted)', fontStyle: 'italic', padding: '0.5rem 0' }}>
Wygenerowano przy wsparciu AI. Przed użyciem zwaliduj dokument poprawnie - autorzy platformy nie ponoszą odpowiedzialności prawnej i finansowej.
</div>
{showVersionsModal && (
<SectionVersionsViewer
projectId={projectId}
sectionId={dbSectionId!}
currentContent={content}
onClose={() => setShowVersionsModal(false)}
onRestore={(restoredContent) => {
setContent(restoredContent);
// Optional: you can manually trigger save here, auto-save will also trigger it but slightly delayed
// setIsSaving(true) ...
}}
/>
)}
<style>{`
.spin { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.tooltip-container:hover .tooltip-content { opacity: 1 !important; visibility: visible !important; }
`}</style>
</motion.div>
);
};
// Alert triangle import for button
import { AlertTriangle } from 'lucide-react';
export default SectionEditor;