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:
2025-12-20 15:55:16 +05:30
parent 89815e7e21
commit d9f6694b2f
19 changed files with 612 additions and 15 deletions

View File

@@ -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 && (

View File

@@ -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>
)
}