feat: Implement source management with file upload, display, and deletion functionality on a new Sources page, backed by new backend models and handlers.
This commit is contained in:
BIN
frontend/public/icons/general/icons8-checkmark-50.png
Normal file
BIN
frontend/public/icons/general/icons8-checkmark-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
BIN
frontend/public/icons/general/icons8-clock-50.png
Normal file
BIN
frontend/public/icons/general/icons8-clock-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/icons/general/icons8-collapse-arrow-50.png
Normal file
BIN
frontend/public/icons/general/icons8-collapse-arrow-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 420 B |
BIN
frontend/public/icons/general/icons8-trash-50.png
Normal file
BIN
frontend/public/icons/general/icons8-trash-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 625 B |
BIN
frontend/public/icons/general/icons8-upload-to-the-cloud-50.png
Normal file
BIN
frontend/public/icons/general/icons8-upload-to-the-cloud-50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 667 B |
@@ -90,9 +90,17 @@ export function DashboardPage() {
|
||||
({categoryBiomarkers.length} biomarkers)
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '18px', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)' }}>
|
||||
▼
|
||||
</span>
|
||||
<img
|
||||
src="/icons/general/icons8-collapse-arrow-50.png"
|
||||
alt="expand"
|
||||
className="theme-icon"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
transition: 'transform 0.2s',
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
@@ -1,4 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Source {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
file_path: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
ocr_data: string | null
|
||||
description: string | null
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export function SourcesPage() {
|
||||
const [sources, setSources] = useState<Source[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Fetch sources on mount
|
||||
useEffect(() => {
|
||||
fetchSources()
|
||||
}, [])
|
||||
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sources', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSources(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch sources:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
// Get current user ID from session
|
||||
const authRes = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
const authData = await authRes.json()
|
||||
if (!authData.user) {
|
||||
setError('Please log in to upload files')
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('user_id', authData.user.id.toString())
|
||||
formData.append('name', file.name)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sources', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(err || 'Upload failed')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Failed to upload ${file.name}`)
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
fetchSources() // Refresh the list
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/sources/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
setSources(sources.filter(s => s.id !== id))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
} finally {
|
||||
setDeleteConfirmId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
@@ -12,30 +122,137 @@ export function SourcesPage() {
|
||||
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error" style={{ marginBottom: 'var(--space-md)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
multiple
|
||||
accept=".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png"
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="upload-zone"
|
||||
className={`upload-zone ${dragOver ? 'drag-over' : ''}`}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-xl)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
backgroundColor: dragOver ? 'color-mix(in srgb, var(--accent) 5%, var(--bg-secondary))' : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '36px', marginBottom: 'var(--space-sm)' }}>📤</div>
|
||||
<p className="text-secondary">
|
||||
Drag & drop files here, or click to browse
|
||||
</p>
|
||||
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
|
||||
Supported: PDF, CSV, XLSX
|
||||
</p>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 'var(--space-sm)', textAlign: 'center' }}>
|
||||
<img src="/icons/general/icons8-clock-50.png" alt="Uploading" className="theme-icon" style={{ width: 36, height: 36, display: 'block', margin: '0 auto' }} />
|
||||
</div>
|
||||
<p className="text-secondary">Uploading...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 'var(--space-sm)', textAlign: 'center' }}>
|
||||
<img src="/icons/general/icons8-upload-to-the-cloud-50.png" alt="Upload" className="theme-icon" style={{ width: 36, height: 36, display: 'block', margin: '0 auto' }} />
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
Drag & drop files here, or click to browse
|
||||
</p>
|
||||
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
|
||||
Supported: PDF, CSV, XLSX, Images
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 'var(--space-lg)' }}>
|
||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Recent Uploads</h3>
|
||||
<p className="text-secondary text-sm">No files uploaded yet.</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-secondary text-sm">Loading...</p>
|
||||
) : sources.length === 0 ? (
|
||||
<p className="text-secondary text-sm">No files uploaded yet.</p>
|
||||
) : (
|
||||
<div className="sources-list">
|
||||
{sources.map(source => (
|
||||
<div key={source.id} className="source-item" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-sm) 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{source.name}
|
||||
</div>
|
||||
<div className="text-secondary text-xs">
|
||||
{source.file_type} • {formatFileSize(source.file_size)} • {formatDate(source.uploaded_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
{source.ocr_data ? (
|
||||
<span style={{ color: 'var(--success)', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<img src="/icons/general/icons8-checkmark-50.png" alt="Parsed" style={{ width: 14, height: 14 }} /> Parsed
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>Pending</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => setDeleteConfirmId(source.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<div className="card" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Delete File?</h3>
|
||||
<p className="text-secondary" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
Are you sure you want to delete this file? This action cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'center' }}>
|
||||
<button className="btn" onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => handleDelete(deleteConfirmId)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user