Spaces:
Sleeping
Sleeping
| 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> | |
| ) | |
| } | |