Spaces:
Build error
Build error
| import { useState } from 'react'; | |
| import type { PageRecord, Task } from '../services/api'; | |
| import { PageParseAPI, API_BASE_URL } from '../services/api'; | |
| import { | |
| History, FileText, X, Calendar, Clock, Copy, Check, Sparkles, Download | |
| } from 'lucide-react'; | |
| interface PageHistoryProps { | |
| pages: PageRecord[]; | |
| tasks: Task[]; | |
| onPagesChanged?: () => void; | |
| onSelectPage?: (page: PageRecord) => void; | |
| } | |
| export const PageHistory = ({ pages, tasks, onPagesChanged, onSelectPage }: PageHistoryProps) => { | |
| const [selectedPage, setSelectedPage] = useState<PageRecord | null>(null); | |
| const [showCleaned, setShowCleaned] = useState(true); | |
| const [copiedRaw, setCopiedRaw] = useState(false); | |
| const [copiedTasks, setCopiedTasks] = useState(false); | |
| const [copiedJSON, setCopiedJSON] = useState(false); | |
| const [isSummarizing, setIsSummarizing] = useState(false); | |
| const openModal = (page: PageRecord) => { | |
| if (onSelectPage) { onSelectPage(page); return; } | |
| setSelectedPage(page); | |
| setShowCleaned(true); | |
| }; | |
| const pageTasks = selectedPage ? tasks.filter(t => t.page_id === selectedPage.id || t.source_id === selectedPage.id) : []; | |
| const handleSummarize = async () => { | |
| if (!selectedPage) return; | |
| setIsSummarizing(true); | |
| try { | |
| const summary = await PageParseAPI.summarizePage(selectedPage.id); | |
| setSelectedPage({ ...selectedPage, summary }); | |
| onPagesChanged?.(); | |
| } catch (err) { console.error(err); } | |
| finally { setIsSummarizing(false); } | |
| }; | |
| return ( | |
| <div className="section"> | |
| <div className="section-header"> | |
| <h3><History size={16} className="text-secondary" /> Ingestion History</h3> | |
| <span className="text-xs text-tertiary">{pages.length} sources</span> | |
| </div> | |
| <div className="section-body"> | |
| <div className="history-list"> | |
| {pages.length === 0 ? ( | |
| <div className="history-empty">No pages ingested yet. Upload a file to get started.</div> | |
| ) : ( | |
| pages.map(page => ( | |
| <div key={page.id} className="history-item" onClick={() => openModal(page)}> | |
| <FileText size={16} className="text-tertiary" /> | |
| <div style={{ flex: 1 }}> | |
| <div className="history-name">{page.filename}</div> | |
| <div className="history-date">{page.captured_date || new Date(page.created_at).toLocaleDateString()}</div> | |
| </div> | |
| {page.summary && <Sparkles size={14} style={{ color: 'var(--purple)' }} />} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* MODAL */} | |
| {selectedPage && !onSelectPage && ( | |
| <div className="modal-overlay" onClick={() => setSelectedPage(null)}> | |
| <div className="modal" onClick={e => e.stopPropagation()}> | |
| <div className="modal-header"> | |
| <h3><FileText size={16} className="text-secondary" /> {selectedPage.filename}</h3> | |
| <div className="flex gap-2 items-center"> | |
| <button className="btn btn-sm btn-ghost" onClick={async () => { | |
| const json = JSON.stringify(selectedPage, null, 2); | |
| await navigator.clipboard.writeText(json); | |
| setCopiedJSON(true); setTimeout(() => setCopiedJSON(false), 2000); | |
| }}>{copiedJSON ? <Check size={12} /> : <Copy size={12} />} JSON</button> | |
| <button className="btn btn-sm btn-ghost" onClick={() => { | |
| const blob = new Blob([JSON.stringify(selectedPage, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = `${selectedPage.filename.split('.')[0]}_data.json`; | |
| a.click(); URL.revokeObjectURL(url); | |
| }}><Download size={12} /></button> | |
| <button className="btn-icon" onClick={() => setSelectedPage(null)}><X size={18} /></button> | |
| </div> | |
| </div> | |
| <div className="modal-body"> | |
| <div className="modal-image-col"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-xs text-tertiary font-semibold">View</span> | |
| <div className="toggle-group"> | |
| <button className={`toggle-btn ${showCleaned ? 'active' : ''}`} onClick={() => setShowCleaned(true)}>Cleaned</button> | |
| <button className={`toggle-btn ${!showCleaned ? 'active' : ''}`} onClick={() => setShowCleaned(false)}>Original</button> | |
| </div> | |
| </div> | |
| <div className="image-box"> | |
| {(showCleaned && selectedPage.cleaned_image_path) ? ( | |
| <img src={selectedPage.cleaned_image_path.startsWith('/') ? `${API_BASE_URL}${selectedPage.cleaned_image_path}` : selectedPage.cleaned_image_path} alt="Cleaned" /> | |
| ) : selectedPage.image_path ? ( | |
| <img src={selectedPage.image_path.startsWith('/') ? `${API_BASE_URL}${selectedPage.image_path}` : selectedPage.image_path} alt="Original" /> | |
| ) : ( | |
| <div className="flex flex-col items-center gap-2 text-tertiary"><FileText size={48} /><span className="text-xs">No preview</span></div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="modal-info-col"> | |
| <div className="meta-grid"> | |
| <div className="meta-item"> | |
| <Calendar size={14} className="text-tertiary" /> | |
| <span className="label">Captured:</span> | |
| <span className="value">{selectedPage.captured_date || '—'}</span> | |
| </div> | |
| <div className="meta-item"> | |
| <Clock size={14} className="text-tertiary" /> | |
| <span className="label">Ingested:</span> | |
| <span className="value">{new Date(selectedPage.created_at).toLocaleDateString()}</span> | |
| </div> | |
| </div> | |
| {/* Summary */} | |
| <div className="summary-box"> | |
| <div className="flex justify-between items-center mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <Sparkles size={14} className="text-secondary" /> | |
| <span className="text-xs font-semibold" style={{ textTransform: 'uppercase', letterSpacing: '0.5px' }}>AI Summary</span> | |
| </div> | |
| <button className="btn btn-sm btn-ghost" onClick={handleSummarize} disabled={isSummarizing}> | |
| {isSummarizing ? '...' : selectedPage.summary ? 'Regenerate' : 'Generate'} | |
| </button> | |
| </div> | |
| {selectedPage.summary ? ( | |
| <p className="summary-text">{selectedPage.summary}</p> | |
| ) : ( | |
| <p className="text-xs text-tertiary" style={{ fontStyle: 'italic' }}>Click Generate to run local AI summarization (Qwen2.5-1.5B).</p> | |
| )} | |
| </div> | |
| {/* Tasks */} | |
| <div> | |
| <div className="flex justify-between items-center mb-3"> | |
| <span className="text-xs font-semibold text-tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.5px' }}>Extracted Records ({pageTasks.length})</span> | |
| {pageTasks.length > 0 && ( | |
| <button className="btn btn-sm btn-ghost" onClick={async () => { | |
| const text = pageTasks.map(t => `- [${t.status === 'done' ? 'x' : ' '}] ${t.task} (${t.priority})`).join('\n'); | |
| await navigator.clipboard.writeText(text); | |
| setCopiedTasks(true); setTimeout(() => setCopiedTasks(false), 2000); | |
| }}>{copiedTasks ? <Check size={12} /> : <Copy size={12} />}</button> | |
| )} | |
| </div> | |
| {pageTasks.length === 0 ? ( | |
| <p className="text-xs text-tertiary" style={{ fontStyle: 'italic' }}>No records extracted.</p> | |
| ) : ( | |
| <div className="derived-list"> | |
| {pageTasks.map(t => ( | |
| <div key={t.id} className="derived-item"> | |
| <span className="dot" /> | |
| <span style={{ flex: 1 }}>{t.task}</span> | |
| <span style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>{(t.ocr_confidence * 100).toFixed(0)}%</span> | |
| {t.priority && <span className={`badge badge-${t.priority}`} style={{ fontSize: 10, padding: '1px 6px' }}>{t.priority}</span>} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Raw Text */} | |
| <div> | |
| <div className="flex justify-between items-center mb-3"> | |
| <span className="text-xs font-semibold text-tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.5px' }}>Raw Extract</span> | |
| <div className="flex gap-2"> | |
| <button className="btn btn-sm btn-ghost" onClick={() => { | |
| const raw = selectedPage.raw_ocr_text || selectedPage.raw_text || ''; | |
| const blob = new Blob([raw], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = `${selectedPage.filename.split('.')[0]}.txt`; | |
| a.click(); URL.revokeObjectURL(url); | |
| }}><Download size={12} /></button> | |
| <button className="btn btn-sm btn-ghost" onClick={async () => { | |
| await navigator.clipboard.writeText(selectedPage.raw_ocr_text || selectedPage.raw_text || ''); | |
| setCopiedRaw(true); setTimeout(() => setCopiedRaw(false), 2000); | |
| }}>{copiedRaw ? <Check size={12} /> : <Copy size={12} />}</button> | |
| </div> | |
| </div> | |
| <div className="raw-text"> | |
| <pre style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap', margin: 0 }}>{(selectedPage.raw_ocr_text || selectedPage.raw_text || 'No text.').slice(0, 1500)}</pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |