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 (

MetabolID

LC-MS Batch Peak Picker · XCMS CentWave Engine

A*STAR Bioinformatics Institute
Free for academic use
© 2026 A*STAR · Commercial licensing: yeohc@a-star.edu.sg
01 · Input
{/* Local Panel */} {tab === 'local' && (
validateAndAdd(e.target.files)} accept=".mzml,.mzxml" multiple style={{ display: 'none' }} /> validateAndAdd(e.target.files)} accept=".mzml,.mzxml" webkitdirectory="" mozdirectory="" style={{ display: 'none' }} />
📂
{totalFiles === 0 ? ( <>Drop your files here
or use the buttons below to browse ) : ( <>{totalFiles} file{totalFiles > 1 ? 's' : ''} selected
Drop more to add )}
{totalFiles > 0 && (
✓ Ready to process {(files.reduce((acc, f) => acc + f.size, 0) / 1024 / 1024).toFixed(1)} MB
)}
{totalFiles > 0 && ( )}
)} {/* Drive Panel */} {tab === 'drive' && (
{driveApiReady === false && (
⚙️ One-time setup required

A Google Drive API key is required in HF Spaces Settings (Secrets). Please configure `GOOGLE_DRIVE_API_KEY`.

)}
setDriveUrl(e.target.value)} placeholder="https://drive.google.com/drive/folders/…" />
⚠ The folder must be set to "Anyone with the link → Viewer".
The app will download all .mzML / .mzXML files from the folder automatically.
)} {error &&
{error}
}
02 · CentWave Parameters
Mass accuracy tolerance
Signal-to-noise cutoff
Shortest expected peak
Increase for lipids
Min scans above I
Min intensity
03 · Run
{/* Results section */} {(jobId || (stats && stats.errors?.length > 0)) && (
04 · Results
{stats && (
{stats.successFiles}
Files Succeeded
{stats.errors?.length || 0}
Files Failed
)} {stats?.errors?.length > 0 && (
{stats.errors.map((e, i) => ( ✗ {e.file}: {e.reason}
))}
)} {jobId && ( ⬇ Download Combined Results )}
)}
{/* Sidebar */}
References

[1] Tautenhahn R, Böttcher C, Neumann S. (2008). Highly sensitive feature detection for high resolution LC/MS. BMC Bioinformatics, 9:504.

[2] Smith CA, et al. (2006). XCMS: processing mass spectrometry data for metabolite profiling using nonlinear peak alignment, matching, and identification. Anal. Chem., 78(3):779–787.

) }