Spaces:
Running
Running
| 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 ; visibility: visible ; } | |
| `}</style> | |
| </motion.div> | |
| ); | |
| }; | |
| // Alert triangle import for button | |
| import { AlertTriangle } from 'lucide-react'; | |
| export default SectionEditor; | |