grantforge-api / frontend-react /src /components /project /SectionVersionsViewer.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Clock, HelpCircle, AlignLeft, RefreshCw, Loader2 } from 'lucide-react';
import { getSectionVersions, restoreSectionVersion, SectionVersion } from '../../api/client';
import toast from 'react-hot-toast';
import { diffWords, diffLines, Change } from 'diff';
import ReactMarkdown from 'react-markdown';
interface SectionVersionsViewerProps {
projectId: string;
sectionId: string;
currentContent: string;
onClose: () => void;
onRestore: (content: string) => void;
}
const SectionVersionsViewer: React.FC<SectionVersionsViewerProps> = ({ projectId, sectionId, currentContent, onClose, onRestore }) => {
const [versions, setVersions] = useState<SectionVersion[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'diff' | 'raw'>('diff');
const [diffMode, setDiffMode] = useState<'words' | 'lines'>('lines');
// Zabezpieczenie przez pomy艂kowym restore
const [isRestoring, setIsRestoring] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
const fetchVersions = async () => {
setIsLoading(true);
try {
const data = await getSectionVersions(projectId, sectionId);
setVersions(data);
if (data.length > 0) {
setSelectedVersionId(data[0].id);
}
} catch (err) {
toast.error("Nie uda艂o si臋 pobra膰 historii sekcji.");
} finally {
setIsLoading(false);
}
};
fetchVersions();
}, [projectId, sectionId]);
const handleRestore = async () => {
if (!selectedVersionId) return;
setIsRestoring(true);
const toastId = toast.loading("Przywracanie wersji...");
try {
await restoreSectionVersion(projectId, sectionId, selectedVersionId);
const version = versions.find(v => v.id === selectedVersionId);
toast.success("Przywr贸cono wybran膮 wersj臋. Aktualny stan zapisano do historii.", { id: toastId });
onRestore(version?.old_content || "");
onClose();
} catch (err) {
toast.error("Wyst膮pi艂 b艂膮d podczas przywracania wersji.", { id: toastId });
setIsRestoring(false);
setShowConfirm(false);
}
};
const selectedVersion = versions.find(v => v.id === selectedVersionId);
// Renderer for diff
const renderDiff = () => {
if (!selectedVersion) return null;
let diffs: Change[] = [];
if (diffMode === 'words') {
diffs = diffWords(selectedVersion.old_content, currentContent || '');
} else {
diffs = diffLines(selectedVersion.old_content, currentContent || '');
}
return (
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.9rem', lineHeight: 1.6, padding: '1rem', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
{diffs.map((part, index) => {
const color = part.added ? '#dcfce7' : part.removed ? '#fee2e2' : 'var(--text-primary)';
const bg = part.added ? 'rgba(34, 197, 94, 0.2)' : part.removed ? 'rgba(239, 68, 68, 0.2)' : 'transparent';
const textDecoration = part.removed ? 'line-through' : 'none';
return (
<span key={index} style={{ color, backgroundColor: bg, textDecoration, padding: part.added || part.removed ? '2px 0' : 0 }}>
{part.value}
</span>
);
})}
</div>
);
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="glass-card"
style={{ width: '100%', maxWidth: '1200px', height: '85vh', display: 'flex', flexDirection: 'column', padding: 0, overflow: 'hidden' }}
>
{/* Header */}
<div style={{ padding: '1.5rem', borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(0,0,0,0.2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
<div style={{ padding: '0.5rem', background: 'rgba(59, 130, 246, 0.1)', borderRadius: '8px', color: '#3b82f6' }}>
<Clock size={24} />
</div>
<div>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--text-primary)' }}>Wersje sekcji</h2>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.2rem' }}>Przegl膮daj historyczne wersje i por贸wnuj je z obecnym stanem</p>
</div>
</div>
<button onClick={onClose} className="btn-icon" title="Zamknij (ESC)">
<X size={24} color="var(--text-muted)" />
</button>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left Panel - History List */}
<div style={{ width: '320px', borderRight: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexDirection: 'column', background: 'rgba(0,0,0,0.15)' }}>
<div style={{ padding: '1rem', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '1px' }}>Dost臋pne wersje</h3>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
{isLoading ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2rem', gap: '1rem' }}>
<Loader2 size={24} className="spin" color="var(--accent-blue)" />
</div>
) : versions.length === 0 ? (
<div style={{ padding: '2rem 1rem', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
To jest pierwsza wersja sekcji.<br/>
Wszystkie kolejne zmiany (r臋czne, z Asystenta lub Autofixu) b臋d膮 tutaj widoczne.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{versions.map((ver, idx) => {
const isSelected = selectedVersionId === ver.id;
const dateObj = new Date(ver.timestamp);
const dateStr = dateObj.toLocaleDateString('pl-PL') + ' ' + dateObj.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
return (
<button
key={ver.id}
onClick={() => { setSelectedVersionId(ver.id); setShowConfirm(false); }}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start', padding: '1rem',
background: isSelected ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
border: `1px solid ${isSelected ? 'rgba(59, 130, 246, 0.3)' : 'transparent'}`,
borderRadius: '8px', cursor: 'pointer', transition: '0.2s',
textAlign: 'left'
}}
className="hover-bg-light"
>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', marginBottom: '0.4rem' }}>
<span style={{ fontWeight: 600, color: isSelected ? '#3b82f6' : 'var(--text-primary)', fontSize: '0.9rem' }}>
Wersja {versions.length - idx}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{dateStr}</span>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginBottom: '0.2rem', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<span style={{ padding: '2px 6px', background: 'rgba(255,255,255,0.05)', borderRadius: '4px' }}>
{ver.author || "Nieznany"}
</span>
</div>
{ver.summary && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontStyle: 'italic', marginTop: '0.3rem' }}>
{ver.summary}
</div>
)}
</button>
);
})}
</div>
)}
</div>
</div>
{/* Right Panel - Content and Actions */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{selectedVersion ? (
<>
{/* Toolbar Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 1.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', background: 'rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', gap: '0.5rem', background: 'rgba(0,0,0,0.3)', padding: '0.2rem', borderRadius: '8px' }}>
<button
onClick={() => setViewMode('diff')}
style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', borderRadius: '6px', background: viewMode === 'diff' ? 'var(--accent-blue)' : 'transparent', color: viewMode === 'diff' ? '#fff' : 'var(--text-secondary)', border: 'none', cursor: 'pointer', transition: '0.2s', display: 'flex', gap: '0.4rem', alignItems: 'center', fontWeight: viewMode === 'diff' ? 600 : 400 }}
>
<HelpCircle size={14} /> Diff (Ostatnia w stosunku do aktualnej)
</button>
<button
onClick={() => setViewMode('raw')}
style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', borderRadius: '6px', background: viewMode === 'raw' ? 'var(--accent-blue)' : 'transparent', color: viewMode === 'raw' ? '#fff' : 'var(--text-secondary)', border: 'none', cursor: 'pointer', transition: '0.2s', display: 'flex', gap: '0.4rem', alignItems: 'center', fontWeight: viewMode === 'raw' ? 600 : 400 }}
>
<AlignLeft size={14} /> Pe艂ny Tekst Wersji
</button>
</div>
{viewMode === 'diff' && (
<div style={{ display: 'flex', fontSize: '0.8rem', gap: '0.5rem', color: 'var(--text-muted)' }}>
Tryb:
<label style={{ display: 'flex', gap: '0.2rem', cursor: 'pointer' }}>
<input type="radio" checked={diffMode === 'words'} onChange={() => setDiffMode('words')} /> S艂owa
</label>
<label style={{ display: 'flex', gap: '0.2rem', cursor: 'pointer' }}>
<input type="radio" checked={diffMode === 'lines'} onChange={() => setDiffMode('lines')} /> Linie
</label>
</div>
)}
</div>
{/* Viewer Area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem', background: 'var(--bg-elevated)', position: 'relative' }}>
{viewMode === 'diff' ? (
<>
<div style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', fontSize: '0.85rem' }}>
<span style={{ color: '#ef4444', background: 'rgba(239, 68, 68, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>Tekst na czerwono by艂 w wybranej wersji, ale nie ma go teraz.</span>
<span style={{ color: '#22c55e', background: 'rgba(34, 197, 94, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>Tekst na zielono dodano w nowszych wersjach.</span>
</div>
{renderDiff()}
</>
) : (
<div className="prose prose-invert" style={{ maxWidth: 'none', opacity: 0.9 }}>
<ReactMarkdown>{selectedVersion.old_content}</ReactMarkdown>
</div>
)}
</div>
{/* Bottom Action Area */}
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', justifyContent: 'flex-end', background: 'rgba(0,0,0,0.2)' }}>
<AnimatePresence>
{showConfirm ? (
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
Czy na pewno chcesz przywr贸ci膰 t臋 wersj臋?<br/>
Aktualna zawarto艣膰 sekcji zostanie automatycznie zapisana jako nowa wersja historii.
</span>
<button onClick={() => setShowConfirm(false)} className="btn hover-bg-light" style={{ padding: '0.5rem 1rem', background: 'rgba(255,255,255,0.05)', color: 'white' }}>
Anuluj
</button>
<button onClick={handleRestore} disabled={isRestoring} className="btn hover-lift" style={{ padding: '0.5rem 1.5rem', background: 'var(--accent-blue)', color: 'white', fontWeight: 600, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{isRestoring ? <Loader2 size={16} className="spin" /> : <RefreshCw size={16} />}
Potwierd藕 przywr贸cenie
</button>
</motion.div>
) : (
<button onClick={() => setShowConfirm(true)} className="btn hover-lift" style={{ padding: '0.6rem 1.5rem', background: 'var(--accent-blue)', color: 'white', fontWeight: 600, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<RefreshCw size={16} /> Przywr贸膰 wybran膮 wersj臋
</button>
)}
</AnimatePresence>
</div>
</>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
Wybierz wersj臋 z lewego panelu aby zobaczy膰 szczeg贸艂y.
</div>
)}
</div>
</div>
</motion.div>
</div>
);
};
export default SectionVersionsViewer;