hardiksood21
feat: new MetabolID UI design and manual download button
edd4f75
Raw
History Blame Contribute Delete
13 kB
import { useState, useRef, useEffect } from 'react'
export default function App() {
const [tab, setTab] = useState('local') // 'local' | 'drive'
const [files, setFiles] = useState([])
const [driveUrl, setDriveUrl] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
// Results
const [jobId, setJobId] = useState(null)
const [stats, setStats] = useState(null)
const [driveApiReady, setDriveApiReady] = useState(null)
const fileInputRef = useRef(null)
const folderInputRef = useRef(null)
const [params, setParams] = useState({
ppm: 15, sn: 1.5, min_pw: 5, max_pw: 20, pref_k: 0, pref_i: 0
})
useEffect(() => {
if (tab === 'drive' && driveApiReady === null) {
fetch('/api/gdrive-status')
.then(r => r.json())
.then(d => setDriveApiReady(d.configured))
.catch(() => setDriveApiReady(false))
}
}, [tab, driveApiReady])
// ── Handlers ─────────────────────────────────────────────────────────────
const validateAndAdd = (selected) => {
setError(null)
setJobId(null)
const valid = Array.from(selected).filter(f => {
const n = f.name.toLowerCase()
return n.endsWith('.mzml') || n.endsWith('.mzxml')
})
const skipped = Array.from(selected).length - valid.length
if (skipped > 0) setError(`${skipped} file(s) skipped — only .mzML / .mzXML allowed.`)
if (valid.length > 0) setFiles(prev => {
const existing = new Set(prev.map(f => f.name))
return [...prev, ...valid.filter(f => !existing.has(f.name))]
})
}
const clearAll = () => { setFiles([]); setJobId(null); setError(null) }
const handleParamChange = (e) => {
const { name, value } = e.target
setParams(p => ({ ...p, [name]: value }))
}
// Drag & drop
const onDragOver = e => { e.preventDefault(); setIsDragging(true) }
const onDragLeave = e => { e.preventDefault(); setIsDragging(false) }
const onDrop = e => { e.preventDefault(); setIsDragging(false); validateAndAdd(e.dataTransfer.files) }
// ── Submission ──────────────────────────────────────────────────────────
const startJob = async () => {
if (tab === 'local' && files.length === 0) { setError('Please select files first.'); return }
if (tab === 'drive' && !driveUrl.trim()) { setError('Please enter a Drive folder URL.'); return }
setIsLoading(true); setError(null); setJobId(null)
try {
let response;
if (tab === 'local') {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
Object.keys(params).forEach(k => formData.append(k, params[k]))
response = await fetch('/api/peak-picker/batch', { method: 'POST', body: formData })
} else {
response = await fetch('/api/peak-picker/gdrive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folderUrl: driveUrl, ...params }),
})
}
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Processing failed')
setJobId(data.jobId)
setStats({
totalFiles: data.total,
successFiles: data.count,
errors: data.errors || []
})
} catch (err) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
// ── Render ──────────────────────────────────────────────────────────────
const totalFiles = files.length;
return (
<div className="page">
<header>
<div className="title-block">
<h1>Metabol<span>ID</span></h1>
<p>LC-MS Batch Peak Picker · XCMS CentWave Engine</p>
</div>
<div className="header-meta">
A*STAR Bioinformatics Institute<br/>
Free for academic use<br/>
<span style={{fontSize: '10px'}}>© 2026 A*STAR · Commercial licensing: <a href="mailto:yeohc@a-star.edu.sg" style={{color: 'var(--ink2)'}}>yeohc@a-star.edu.sg</a></span>
</div>
</header>
<div className="layout">
<div className="main-col">
<div className="section-label">01 · Input</div>
<div className="card">
<div className="mode-tabs">
<button className={`tab-btn ${tab === 'local' ? 'active' : ''}`} onClick={() => {setTab('local'); setError(null)}}>
<strong>📁 Upload Files / Folder</strong>
Select multiple .mzML/.mzXML files<br/>or an entire directory
</button>
<button className={`tab-btn ${tab === 'drive' ? 'active' : ''}`} onClick={() => {setTab('drive'); setError(null)}}>
<strong>☁ Google Drive Folder</strong>
Paste a shared folder link<br/>Any size · No upload limit
</button>
</div>
{/* Local Panel */}
{tab === 'local' && (
<div className="input-panel active">
<div
className={`drop-zone ${isDragging ? 'drag-active' : ''}`}
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
>
<input type="file" ref={fileInputRef} onChange={e => validateAndAdd(e.target.files)} accept=".mzml,.mzxml" multiple style={{ display: 'none' }} />
<input type="file" ref={folderInputRef} onChange={e => validateAndAdd(e.target.files)} accept=".mzml,.mzxml" webkitdirectory="" mozdirectory="" style={{ display: 'none' }} />
<div className="drop-icon">📂</div>
<div className="drop-text">
{totalFiles === 0 ? (
<><strong>Drop your files here</strong><br/>or use the buttons below to browse</>
) : (
<><strong>{totalFiles} file{totalFiles > 1 ? 's' : ''} selected</strong><br/>Drop more to add</>
)}
</div>
{totalFiles > 0 && (
<div className="file-chosen">
✓ Ready to process {(files.reduce((acc, f) => acc + f.size, 0) / 1024 / 1024).toFixed(1)} MB
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', justifyContent: 'center' }}>
<button className="btn-secondary" onClick={() => fileInputRef.current?.click()}>Browse Files</button>
<button className="btn-secondary" onClick={() => folderInputRef.current?.click()}>Select Folder</button>
{totalFiles > 0 && (
<button className="btn-secondary" onClick={clearAll} style={{ color: '#EB5757' }}>Clear All</button>
)}
</div>
</div>
)}
{/* Drive Panel */}
{tab === 'drive' && (
<div className="input-panel active">
{driveApiReady === false && (
<div className="alert-warning">
<strong>⚙️ One-time setup required</strong>
<p style={{ marginTop: '0.4rem' }}>A Google Drive API key is required in HF Spaces Settings (Secrets). Please configure `GOOGLE_DRIVE_API_KEY`.</p>
</div>
)}
<div className="drive-input-wrap">
<input type="text" value={driveUrl} onChange={e => setDriveUrl(e.target.value)} placeholder="https://drive.google.com/drive/folders/…" />
</div>
<div className="drive-hint">
⚠ The folder must be set to <strong>"Anyone with the link → Viewer"</strong>.<br/>
The app will download all .mzML / .mzXML files from the folder automatically.
</div>
</div>
)}
{error && <div className="alert-error" style={{marginTop: '1rem'}}>{error}</div>}
</div>
<div className="section-label">02 · CentWave Parameters</div>
<div className="card">
<div className="param-grid">
<div className="param-item">
<label>PPM Error</label>
<input type="number" name="ppm" value={params.ppm} onChange={handleParamChange} />
<div className="param-hint">Mass accuracy tolerance</div>
</div>
<div className="param-item">
<label>S/N Threshold</label>
<input type="number" name="sn" value={params.sn} onChange={handleParamChange} step="0.1" />
<div className="param-hint">Signal-to-noise cutoff</div>
</div>
<div className="param-item">
<label>Min Peakwidth (s)</label>
<input type="number" name="min_pw" value={params.min_pw} onChange={handleParamChange} />
<div className="param-hint">Shortest expected peak</div>
</div>
<div className="param-item">
<label>Max Peakwidth (s)</label>
<input type="number" name="max_pw" value={params.max_pw} onChange={handleParamChange} />
<div className="param-hint">Increase for lipids</div>
</div>
<div className="param-item">
<label>Prefilter k</label>
<input type="number" name="pref_k" value={params.pref_k} onChange={handleParamChange} />
<div className="param-hint">Min scans above I</div>
</div>
<div className="param-item">
<label>Prefilter I</label>
<input type="number" name="pref_i" value={params.pref_i} onChange={handleParamChange} />
<div className="param-hint">Min intensity</div>
</div>
</div>
</div>
<div className="section-label">03 · Run</div>
<div className="card">
<button className="btn-run" onClick={startJob} disabled={isLoading || (tab==='local' && files.length===0) || (tab==='drive' && !driveUrl)}>
{isLoading ? (
<><span className="loader"></span> Processing... This may take a while depending on file size.</>
) : (
<>⚡ Extract Peaks from All Files</>
)}
</button>
</div>
{/* Results section */}
{(jobId || (stats && stats.errors?.length > 0)) && (
<div className="results-section show">
<div className="section-label">04 · Results</div>
<div className="card">
{stats && (
<div className="stats-row">
<div className="stat-box">
<div className="stat-val">{stats.successFiles}</div>
<div className="stat-label">Files Succeeded</div>
</div>
<div className="stat-box">
<div className="stat-val" style={{color: stats.errors?.length ? '#EB5757' : 'inherit'}}>{stats.errors?.length || 0}</div>
<div className="stat-label">Files Failed</div>
</div>
</div>
)}
{stats?.errors?.length > 0 && (
<div className="log-box" style={{marginBottom: '1rem'}}>
{stats.errors.map((e, i) => (
<span key={i} className="log-err">✗ {e.file}: {e.reason}<br/></span>
))}
</div>
)}
{jobId && (
<a className="btn-download" href={`/api/peak-picker/download/${jobId}`}>
⬇ Download Combined Results
</a>
)}
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="sidebar">
<div className="sidebar-card">
<strong>References</strong>
<p>[1] Tautenhahn R, Böttcher C, Neumann S. (2008). <em>Highly sensitive feature detection for high resolution LC/MS.</em> BMC Bioinformatics, 9:504.</p>
<p>[2] Smith CA, et al. (2006). <em>XCMS: processing mass spectrometry data for metabolite profiling using nonlinear peak alignment, matching, and identification.</em> Anal. Chem., 78(3):779–787.</p>
</div>
</div>
</div>
</div>
)
}