Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import React from 'react'; | |
| import { MOCK_DOCUMENTS } from '@/lib/mockData'; | |
| import { type Document, getPageImageUrl } from '@/lib/api'; | |
| interface RightEvidencePanelProps { | |
| open: boolean; | |
| docId?: string; | |
| docName?: string; | |
| filename?: string; | |
| page?: number; | |
| onClose: () => void; | |
| totalPages?: number; | |
| // Document Library Props | |
| documents: Document[]; | |
| selectedDocIds: string[]; | |
| onToggleDoc: (docId: string) => void; | |
| activeDocId?: string; | |
| onSelectDoc?: (docId: string) => void; | |
| onOpenPage?: (docName: string, page: number, docId: string, filename: string) => void; | |
| } | |
| export function RightEvidencePanel({ | |
| open, | |
| docId, | |
| docName, | |
| filename, | |
| page = 1, | |
| onClose, | |
| totalPages = 36, | |
| documents, | |
| selectedDocIds, | |
| onToggleDoc, | |
| activeDocId, | |
| onSelectDoc, | |
| onOpenPage, | |
| }: RightEvidencePanelProps) { | |
| const [search, setSearch] = React.useState(''); | |
| const [zoom, setZoom] = React.useState(1.0); | |
| const [fullscreenOpen, setFullscreenOpen] = React.useState(false); | |
| const [currentPage, setCurrentPage] = React.useState(page); | |
| const [pageInputValue, setPageInputValue] = React.useState(String(page)); | |
| const [imageError, setImageError] = React.useState(false); | |
| const viewerContainerRef = React.useRef<HTMLDivElement>(null); | |
| // Sync currentPage with incoming page prop | |
| React.useEffect(() => { | |
| React.startTransition(() => { | |
| setCurrentPage(page); | |
| setPageInputValue(String(page)); | |
| }); | |
| }, [page]); | |
| // Reset image error on imageUrl change | |
| React.useEffect(() => { | |
| React.startTransition(() => { | |
| setImageError(false); | |
| }); | |
| }, [page, docId]); | |
| if (!open) return null; | |
| // Construct slide image URL through the backend resolver. | |
| const imageUrl = docId | |
| ? getPageImageUrl(docId, currentPage) | |
| : `/mock-page.png`; | |
| const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.15, 2.0)); | |
| const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.15, 0.5)); | |
| const handleResetViewer = () => { | |
| const resetPage = Math.max(1, Math.min(page, totalPages)); | |
| setZoom(1.0); | |
| setCurrentPage(resetPage); | |
| setPageInputValue(String(resetPage)); | |
| setImageError(false); | |
| if (viewerContainerRef.current) { | |
| viewerContainerRef.current.scrollTop = 0; | |
| viewerContainerRef.current.scrollLeft = 0; | |
| } | |
| }; | |
| const handlePageChange = (pageNum: number) => { | |
| const validPage = Math.max(1, Math.min(pageNum, totalPages)); | |
| setCurrentPage(validPage); | |
| setPageInputValue(String(validPage)); | |
| }; | |
| const handlePageInputSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| const val = parseInt(pageInputValue); | |
| if (!isNaN(val)) { | |
| handlePageChange(val); | |
| } | |
| }; | |
| const handleOpenFullScreen = () => { | |
| setFullscreenOpen(true); | |
| }; | |
| const normalizedDocs: Document[] = documents.length > 0 ? documents : MOCK_DOCUMENTS.map(d => ({ | |
| doc_id: d.id, | |
| name: d.name, | |
| doc_type: d.type, | |
| period: d.period, | |
| total_pages: d.pages, | |
| status: d.status, | |
| filename: d.filename, | |
| })); | |
| const filtered = normalizedDocs.filter(d => | |
| d.name.toLowerCase().includes(search.toLowerCase()) || | |
| d.doc_type.toLowerCase().includes(search.toLowerCase()) | |
| ); | |
| return ( | |
| <aside | |
| className="app-right" | |
| style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| width: 'var(--right-panel)', | |
| borderLeft: '1px solid var(--border-strong)', | |
| }} | |
| > | |
| {/* Header */} | |
| <div className="pdf-panel-header" style={{ padding: '0.75rem 1rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> | |
| <div style={{ minWidth: 0, flex: 1 }}> | |
| <span style={{ fontSize: '0.75rem', fontWeight: 800, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--enbd-blue)' }}> | |
| Source Workspace & Library | |
| </span> | |
| <div style={{ fontSize: '0.62rem', color: 'var(--text-disabled)', marginTop: '2px' }}> | |
| Document browser and evidence tools | |
| </div> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| id="close-pdf-panel-btn" | |
| style={{ | |
| background: 'none', | |
| border: 'none', | |
| cursor: 'pointer', | |
| color: 'var(--text-muted)', | |
| padding: '0.25rem', | |
| borderRadius: 'var(--radius-sm)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| }} | |
| > | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <line x1="18" y1="6" x2="6" y2="18"/> | |
| <line x1="6" y1="6" x2="18" y2="18"/> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* PART A: Document Library Section (Top) */} | |
| <div style={{ | |
| background: 'var(--bg-mid)', | |
| borderBottom: '2px solid var(--enbd-blue-border)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| maxHeight: '40%', | |
| }}> | |
| {/* Search & Counter */} | |
| <div style={{ | |
| padding: '0.65rem 0.875rem', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '0.5rem', | |
| borderBottom: '1px solid var(--border-subtle)', | |
| }}> | |
| <div style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '0.4rem', | |
| background: 'var(--bg-input)', | |
| border: '1px solid var(--border-mid)', | |
| borderRadius: 'var(--radius-sm)', | |
| padding: '0.3rem 0.5rem', | |
| flex: 1, | |
| }}> | |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2.5"> | |
| <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/> | |
| </svg> | |
| <input | |
| value={search} | |
| onChange={e => setSearch(e.target.value)} | |
| placeholder="Search library documents…" | |
| style={{ | |
| background: 'none', | |
| border: 'none', | |
| outline: 'none', | |
| fontSize: '0.72rem', | |
| color: 'var(--text-primary)', | |
| width: '100%', | |
| }} | |
| /> | |
| </div> | |
| <span className="badge badge-gold" style={{ fontSize: '0.62rem', padding: '0.15rem 0.4rem' }}> | |
| {normalizedDocs.length} Docs | |
| </span> | |
| </div> | |
| {/* Tree List Container */} | |
| <div style={{ overflowY: 'auto', padding: '0.5rem 0.875rem' }}> | |
| {filtered.map(doc => { | |
| const isSelected = selectedDocIds.includes(doc.doc_id); | |
| const isIndexed = doc.status === 'indexed'; | |
| const isIndexing = doc.status === 'indexing'; | |
| const isError = doc.status === 'error'; | |
| return ( | |
| <div | |
| key={doc.doc_id} | |
| style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| borderBottom: '1px solid var(--border-subtle)', | |
| padding: '0.35rem 0', | |
| }} | |
| > | |
| {/* Header Row */} | |
| <div | |
| className={`doc-item ${activeDocId === doc.doc_id ? 'active' : ''}`} | |
| onClick={() => { | |
| if (isIndexed) { | |
| onSelectDoc?.(doc.doc_id); | |
| onOpenPage?.(doc.name, 1, doc.doc_id, doc.filename); | |
| } | |
| }} | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| cursor: isIndexed ? 'pointer' : 'default', | |
| opacity: isIndexed ? 1 : 0.75, | |
| padding: '0.35rem 0.4rem', | |
| borderRadius: 'var(--radius-sm)', | |
| }} | |
| > | |
| {/* Scope target checkbox */} | |
| <div | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (isIndexed) { | |
| onToggleDoc?.(doc.doc_id); | |
| } | |
| }} | |
| style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| marginRight: '0.5rem', | |
| }} | |
| > | |
| <input | |
| type="checkbox" | |
| checked={isSelected} | |
| disabled={!isIndexed} | |
| readOnly | |
| style={{ | |
| cursor: isIndexed ? 'pointer' : 'not-allowed', | |
| accentColor: 'var(--enbd-blue)', | |
| }} | |
| /> | |
| </div> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 600 : 400, fontSize: '0.76rem', color: 'var(--text-primary)' }}> | |
| 📄 {doc.name} | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.15rem' }}> | |
| <span className="badge badge-info" style={{ fontSize: '0.58rem' }}>{doc.doc_type}</span> | |
| {doc.period && doc.period !== 'Unknown' && ( | |
| <span style={{ fontSize: '0.62rem', color: 'var(--text-disabled)' }}>{doc.period}</span> | |
| )} | |
| {isIndexing && ( | |
| <span style={{ fontSize: '0.62rem', color: 'var(--enbd-blue)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.2rem' }}> | |
| <span style={{ width: 6, height: 6, border: '1.2px solid var(--enbd-blue)', borderTopColor: 'transparent', borderRadius: '50%', display: 'inline-block', animation: 'spin 1s linear infinite' }}/> | |
| Indexing... | |
| </span> | |
| )} | |
| {isError && <span style={{ fontSize: '0.62rem', color: 'var(--danger)', fontWeight: 600 }}>Error</span>} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {filtered.length === 0 && ( | |
| <div style={{ padding: '1rem 0', textAlign: 'center', color: 'var(--text-disabled)', fontSize: '0.72rem' }}> | |
| No documents found | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* PART B: PDF Page Viewer Section (Bottom) */} | |
| <div style={{ | |
| flex: 1, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| background: '#ECEFF1', | |
| }}> | |
| {docId ? ( | |
| <> | |
| {/* Toolbar */} | |
| <div style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| padding: '0.4rem 0.875rem', | |
| borderBottom: '1px solid var(--border-subtle)', | |
| background: 'var(--bg-high)', | |
| flexWrap: 'wrap', | |
| gap: '0.35rem', | |
| }}> | |
| {/* Page jump */} | |
| <form onSubmit={handlePageInputSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}> | |
| <button | |
| type="button" | |
| disabled={currentPage <= 1} | |
| onClick={() => handlePageChange(currentPage - 1)} | |
| style={{ background: 'none', border: 'none', cursor: currentPage <= 1 ? 'not-allowed' : 'pointer', fontSize: '0.75rem', color: 'var(--text-secondary)' }} | |
| > | |
| ◀ | |
| </button> | |
| <input | |
| value={pageInputValue} | |
| onChange={e => setPageInputValue(e.target.value)} | |
| style={{ | |
| width: '30px', | |
| textAlign: 'center', | |
| border: '1px solid var(--border-mid)', | |
| borderRadius: 'var(--radius-sm)', | |
| fontSize: '0.7rem', | |
| padding: '0.1rem', | |
| background: 'var(--bg-card)', | |
| color: 'var(--text-primary)', | |
| }} | |
| /> | |
| <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>/ {totalPages}</span> | |
| <button | |
| type="button" | |
| disabled={currentPage >= totalPages} | |
| onClick={() => handlePageChange(currentPage + 1)} | |
| style={{ background: 'none', border: 'none', cursor: currentPage >= totalPages ? 'not-allowed' : 'pointer', fontSize: '0.75rem', color: 'var(--text-secondary)' }} | |
| > | |
| ▶ | |
| </button> | |
| </form> | |
| {/* PDF Actions & Zoom */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}> | |
| <button onClick={handleZoomOut} style={{ padding: '0.15rem 0.35rem', border: '1px solid var(--border-mid)', borderRadius: 'var(--radius-sm)', background: 'var(--bg-card)', cursor: 'pointer', fontSize: '0.65rem' }}>➖</button> | |
| <span style={{ fontSize: '0.65rem', minWidth: '25px', textAlign: 'center', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)' }}>{Math.round(zoom * 100)}%</span> | |
| <button onClick={handleZoomIn} style={{ padding: '0.15rem 0.35rem', border: '1px solid var(--border-mid)', borderRadius: 'var(--radius-sm)', background: 'var(--bg-card)', cursor: 'pointer', fontSize: '0.65rem' }}>➕</button> | |
| <button onClick={handleResetViewer} style={{ padding: '0.15rem 0.35rem', border: '1px solid var(--border-mid)', borderRadius: 'var(--radius-sm)', background: 'var(--bg-card)', cursor: 'pointer', fontSize: '0.62rem' }}>Reset</button> | |
| <button | |
| onClick={handleOpenFullScreen} | |
| style={{ | |
| padding: '0.15rem 0.35rem', | |
| border: '1px solid var(--border-mid)', | |
| borderRadius: 'var(--radius-sm)', | |
| background: 'var(--bg-card)', | |
| cursor: 'pointer', | |
| fontSize: '0.62rem', | |
| color: 'var(--text-secondary)', | |
| }} | |
| > | |
| 📸 Screen | |
| </button> | |
| </div> | |
| </div> | |
| {/* Viewer Screen Canvas */} | |
| <div | |
| ref={viewerContainerRef} | |
| style={{ | |
| flex: 1, | |
| overflow: 'auto', | |
| padding: '1rem', | |
| display: 'flex', | |
| justifyContent: 'center', | |
| alignItems: 'flex-start', | |
| position: 'relative', | |
| }} | |
| > | |
| <div style={{ | |
| position: 'relative', | |
| transform: `scale(${zoom})`, | |
| transformOrigin: 'top center', | |
| transition: 'transform 0.15s ease-out', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.1)', | |
| borderRadius: 'var(--radius-sm)', | |
| background: '#FFFFFF', | |
| width: '100%', | |
| maxWidth: '420px', | |
| }}> | |
| {/* eslint-disable-next-line @next/next/no-img-element */} | |
| <img | |
| src={imageError ? '/mock-page.png' : imageUrl} | |
| alt="PDF page snapshot" | |
| onError={() => setImageError(true)} | |
| style={{ width: '100%', display: 'block' }} | |
| /> | |
| </div> | |
| </div> | |
| {/* Viewer Footer Filename info */} | |
| <div style={{ | |
| padding: '0.45rem 0.875rem', | |
| background: 'var(--bg-mid)', | |
| borderTop: '1px solid var(--border-subtle)', | |
| fontSize: '0.68rem', | |
| color: 'var(--text-muted)', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| fontWeight: 500, | |
| }}> | |
| Open: <span style={{ color: 'var(--enbd-blue)', fontWeight: 600 }}>{filename || docName}</span> (Page {currentPage}) | |
| </div> | |
| </> | |
| ) : ( | |
| <div style={{ | |
| flex: 1, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: '2rem', | |
| textAlign: 'center', | |
| color: 'var(--text-disabled)', | |
| }}> | |
| <span style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📂</span> | |
| <div style={{ fontSize: '0.82rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.25rem' }}> | |
| No Source Document Selected | |
| </div> | |
| <p style={{ fontSize: '0.72rem', color: 'var(--text-disabled)', maxWidth: '280px', lineHeight: 1.5 }}> | |
| Select a source PDF file or double-click page screenshots in the library above to load the viewer workspace. | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| {fullscreenOpen && ( | |
| <div className="pdf-screen-modal" role="dialog" aria-modal="true" aria-label="Expanded source screen"> | |
| <div className="pdf-screen-modal-header"> | |
| <div> | |
| <div className="pdf-screen-modal-title">{filename || docName || 'Source document'}</div> | |
| <div className="pdf-screen-modal-meta">Page {currentPage} of {totalPages}</div> | |
| </div> | |
| <form className="pdf-screen-page-nav" onSubmit={handlePageInputSubmit}> | |
| <button | |
| type="button" | |
| disabled={currentPage <= 1} | |
| onClick={() => handlePageChange(currentPage - 1)} | |
| > | |
| Prev | |
| </button> | |
| <input | |
| value={pageInputValue} | |
| onChange={e => setPageInputValue(e.target.value)} | |
| aria-label="Page number" | |
| /> | |
| <span>/ {totalPages}</span> | |
| <button | |
| type="button" | |
| disabled={currentPage >= totalPages} | |
| onClick={() => handlePageChange(currentPage + 1)} | |
| > | |
| Next | |
| </button> | |
| </form> | |
| <div className="pdf-screen-modal-actions"> | |
| <button type="button" onClick={handleZoomOut} aria-label="Zoom out">−</button> | |
| <span className="pdf-screen-zoom-value">{Math.round(zoom * 100)}%</span> | |
| <button type="button" onClick={handleZoomIn} aria-label="Zoom in">+</button> | |
| <button type="button" onClick={handleResetViewer}>Reset</button> | |
| <button type="button" onClick={() => setFullscreenOpen(false)}>Close</button> | |
| </div> | |
| </div> | |
| <div className="pdf-screen-modal-body" onClick={() => setFullscreenOpen(false)}> | |
| <div | |
| className="pdf-screen-modal-page" | |
| onClick={e => e.stopPropagation()} | |
| style={{ transform: `scale(${zoom})` }} | |
| > | |
| {/* eslint-disable-next-line @next/next/no-img-element */} | |
| <img | |
| src={imageError ? '/mock-page.png' : imageUrl} | |
| alt={`Expanded PDF page ${currentPage}`} | |
| onError={() => setImageError(true)} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </aside> | |
| ); | |
| } | |