Spaces:
Running
Running
| 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; | |