Spaces:
Running
Running
File size: 14,566 Bytes
afd56bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | 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;
|