392 lines
16 KiB
React
392 lines
16 KiB
React
import { useState, useCallback, useEffect, useRef } from 'react'
|
||
import { useDropzone } from 'react-dropzone'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import api from '../utils/api'
|
||
|
||
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||
const [tasks, setTasks] = useState([])
|
||
const queryClient = useQueryClient()
|
||
const intervalsRef = useRef({})
|
||
|
||
const pollTask = useCallback((taskId) => {
|
||
if (intervalsRef.current[taskId]) return
|
||
const intervalId = setInterval(async () => {
|
||
try {
|
||
const { data } = await api.get(`/upload/task/${taskId}`)
|
||
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
||
clearInterval(intervalsRef.current[taskId])
|
||
delete intervalsRef.current[taskId]
|
||
// A successful task may still have skipped the file (e.g. a duplicate or
|
||
// an activity that looks like vehicle travel) — surface the reason.
|
||
const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped'
|
||
setTasks(ts => ts.map(t =>
|
||
t.task_id === taskId
|
||
? { ...t,
|
||
status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done',
|
||
reason: skipped ? data.result?.reason : t.reason }
|
||
: t
|
||
))
|
||
if (data.status === 'SUCCESS' && !skipped) {
|
||
queryClient.invalidateQueries({ queryKey: ['activities'] })
|
||
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
|
||
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
|
||
}
|
||
}
|
||
} catch { /* ignore transient poll errors */ }
|
||
}, 2000)
|
||
intervalsRef.current[taskId] = intervalId
|
||
}, [queryClient])
|
||
|
||
useEffect(() => {
|
||
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
|
||
}, [])
|
||
|
||
const upload = useMutation({
|
||
mutationFn: async (file) => {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const { data } = await api.post(endpoint, form, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})
|
||
return { file: file.name, ...data }
|
||
},
|
||
onSuccess: (data) => {
|
||
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
|
||
setTasks(t => [...t, task])
|
||
if (data.task_id) {
|
||
pollTask(data.task_id)
|
||
}
|
||
},
|
||
onError: (err, file) => {
|
||
const reason = err.response?.data?.detail || 'Upload failed'
|
||
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
|
||
},
|
||
})
|
||
|
||
const onDrop = useCallback((accepted) => {
|
||
accepted.forEach(file => upload.mutate(file))
|
||
}, [upload])
|
||
|
||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||
onDrop,
|
||
accept,
|
||
multiple: true,
|
||
})
|
||
|
||
function StatusBadge({ status }) {
|
||
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse">⏳ Processing</span>
|
||
if (status === 'done') return <span className="ml-2 text-green-400">✓ Done</span>
|
||
if (status === 'skipped') return <span className="ml-2 text-amber-400">⚠ Skipped</span>
|
||
if (status === 'failed') return <span className="ml-2 text-red-400">✗ Failed</span>
|
||
return <span className="ml-2 text-green-400">✓ Queued</span>
|
||
}
|
||
|
||
return (
|
||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<span className="text-2xl">{icon}</span>
|
||
<div>
|
||
<h3 className="font-semibold text-white">{title}</h3>
|
||
<p className="text-xs text-gray-500">{description}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
{...getRootProps()}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||
isDragActive
|
||
? 'border-blue-500 bg-blue-950/30'
|
||
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
||
}`}
|
||
>
|
||
<input {...getInputProps()} />
|
||
{isDragActive ? (
|
||
<p className="text-blue-400 text-sm">Drop files here…</p>
|
||
) : (
|
||
<div>
|
||
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
|
||
<p className="text-gray-600 text-xs mt-1">
|
||
{Object.values(accept).flat().join(', ')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{upload.isPending && (
|
||
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading…</p>
|
||
)}
|
||
|
||
{tasks.length > 0 && (
|
||
<div className="mt-4 space-y-2">
|
||
{tasks.map((task, i) => (
|
||
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||
{task.activity_tasks !== undefined && (
|
||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||
)}
|
||
<StatusBadge status={task.status} />
|
||
</div>
|
||
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
|
||
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
|
||
{task.reason}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Strava bulk export with a dry-run preview: upload → analyse (new vs. already
|
||
// present, Garmin preferred) → confirm import of just the new ones. Avoids
|
||
// re-uploading by importing the files the preview already extracted (via token).
|
||
function StravaExportZone() {
|
||
const qc = useQueryClient()
|
||
const [phase, setPhase] = useState('idle') // idle|uploading|analyzing|report|importing|done|error
|
||
const [report, setReport] = useState(null)
|
||
const [token, setToken] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const pollRef = useRef(null)
|
||
|
||
const reset = () => { setReport(null); setToken(null); setError('') }
|
||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||
|
||
const pollAnalyze = useCallback((taskId) => {
|
||
pollRef.current = setInterval(async () => {
|
||
try {
|
||
const { data } = await api.get(`/upload/task/${taskId}`)
|
||
if (data.status === 'SUCCESS') {
|
||
clearInterval(pollRef.current)
|
||
setReport(data.result)
|
||
setPhase('report')
|
||
} else if (data.status === 'FAILURE') {
|
||
clearInterval(pollRef.current)
|
||
setError('Analysis failed — please try again')
|
||
setPhase('error')
|
||
}
|
||
} catch { /* ignore transient poll errors */ }
|
||
}, 2000)
|
||
}, [])
|
||
|
||
const onDrop = useCallback(async (accepted) => {
|
||
const file = accepted[0]
|
||
if (!file) return
|
||
reset()
|
||
setPhase('uploading')
|
||
try {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const { data } = await api.post('/upload/strava-export?dry_run=true', form, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})
|
||
setToken(data.token)
|
||
setPhase('analyzing')
|
||
pollAnalyze(data.task_id)
|
||
} catch (e) {
|
||
setError(e.response?.data?.detail || 'Upload failed')
|
||
setPhase('error')
|
||
}
|
||
}, [pollAnalyze])
|
||
|
||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||
onDrop, accept: { 'application/zip': ['.zip'] }, multiple: false,
|
||
})
|
||
|
||
const doImport = async () => {
|
||
setPhase('importing')
|
||
try {
|
||
await api.post('/upload/strava-export/confirm', { token })
|
||
setPhase('done')
|
||
qc.invalidateQueries({ queryKey: ['activities'] })
|
||
} catch (e) {
|
||
setError(e.response?.data?.detail || 'Import failed')
|
||
setPhase('error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<span className="text-2xl">🚴</span>
|
||
<div>
|
||
<h3 className="font-semibold text-white">Strava bulk export</h3>
|
||
<p className="text-xs text-gray-500">Preview duplicates before importing — Garmin data is kept on a match</p>
|
||
</div>
|
||
</div>
|
||
|
||
{(phase === 'idle' || phase === 'uploading') && (
|
||
<div
|
||
{...getRootProps()}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||
isDragActive ? 'border-blue-500 bg-blue-950/30' : 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
||
}`}
|
||
>
|
||
<input {...getInputProps()} />
|
||
{phase === 'uploading'
|
||
? <p className="text-blue-400 text-sm animate-pulse">Uploading…</p>
|
||
: <div>
|
||
<p className="text-gray-400 text-sm">Drag & drop your Strava archive .zip, or click to browse</p>
|
||
<p className="text-gray-600 text-xs mt-1">We’ll scan it and show what’s new before importing</p>
|
||
</div>}
|
||
</div>
|
||
)}
|
||
|
||
{phase === 'analyzing' && (
|
||
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Scanning archive for duplicates…</p>
|
||
)}
|
||
|
||
{phase === 'report' && report && (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-3 gap-2 text-center">
|
||
<div className="bg-green-950/30 border border-green-900/40 rounded-lg py-2">
|
||
<div className="text-xl font-bold text-green-400">{report.new}</div>
|
||
<div className="text-xs text-gray-500">new</div>
|
||
</div>
|
||
<div className="bg-gray-800 border border-gray-700 rounded-lg py-2">
|
||
<div className="text-xl font-bold text-gray-300">{report.duplicate}</div>
|
||
<div className="text-xs text-gray-500">already have</div>
|
||
</div>
|
||
<div className="bg-amber-950/20 border border-amber-900/40 rounded-lg py-2">
|
||
<div className="text-xl font-bold text-amber-400">{report.unreadable}</div>
|
||
<div className="text-xs text-gray-500">unreadable</div>
|
||
</div>
|
||
</div>
|
||
{report.new_samples?.length > 0 && (
|
||
<p className="text-xs text-gray-500">
|
||
New e.g.: <span className="text-gray-400">{report.new_samples.slice(0, 4).join(' · ')}</span>
|
||
{report.new > 4 ? ' …' : ''}
|
||
</p>
|
||
)}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<button
|
||
onClick={doImport}
|
||
disabled={report.new === 0}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||
{report.new === 0 ? 'Nothing new to import' : `Import ${report.new} new ${report.new === 1 ? 'activity' : 'activities'}`}
|
||
</button>
|
||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||
Choose another file
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-600">Duplicates are skipped automatically — your existing Garmin activities are kept.</p>
|
||
</div>
|
||
)}
|
||
|
||
{phase === 'importing' && (
|
||
<p className="text-sm text-blue-400 animate-pulse py-4 text-center">Queuing import…</p>
|
||
)}
|
||
|
||
{phase === 'done' && (
|
||
<div className="py-3 space-y-2">
|
||
<p className="text-sm text-green-400">✓ Import queued — new activities will appear shortly as they process.</p>
|
||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||
Import another export
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{phase === 'error' && (
|
||
<div className="py-3 space-y-2">
|
||
<p className="text-sm text-red-400">{error || 'Something went wrong'}</p>
|
||
<button onClick={() => setPhase('idle')} className="text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||
Try again
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function UploadPage() {
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
||
<p className="text-gray-500 text-sm mt-1">
|
||
Import activities from Garmin or Strava. Large exports are processed in the background.
|
||
</p>
|
||
</div>
|
||
|
||
{/* How to export guides */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
|
||
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
|
||
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||
<li>Go to Garmin Connect → Profile → Account</li>
|
||
<li>Scroll to Data Management → Export Your Data</li>
|
||
<li>Request export and wait for the email</li>
|
||
<li>Download and upload the ZIP file below</li>
|
||
</ol>
|
||
</div>
|
||
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
|
||
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
|
||
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||
<li>Go to strava.com → Settings → My Account</li>
|
||
<li>Scroll to Download or Delete Your Account</li>
|
||
<li>Click "Request Your Archive"</li>
|
||
<li>Download and upload the ZIP file below</li>
|
||
</ol>
|
||
<p className="text-gray-500 text-xs mt-2">Handles the gzipped <code>.fit.gz</code>/<code>.gpx.gz</code>/<code>.tcx.gz</code> files Strava puts in the archive. For ongoing sync, connect Strava on the Profile page instead.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||
{/* Single FIT/GPX/TCX */}
|
||
<UploadZone
|
||
title="Single activity"
|
||
description="Upload a .fit, .gpx or .tcx file"
|
||
icon="🏃"
|
||
endpoint="/upload/activity"
|
||
accept={{
|
||
'application/octet-stream': ['.fit'],
|
||
'application/gpx+xml': ['.gpx'],
|
||
'text/xml': ['.gpx', '.tcx'],
|
||
}}
|
||
/>
|
||
|
||
{/* Garmin full export */}
|
||
<UploadZone
|
||
title="Garmin Connect export"
|
||
description="Upload your full Garmin data export ZIP"
|
||
icon="⌚"
|
||
endpoint="/upload/garmin-export"
|
||
accept={{ 'application/zip': ['.zip'] }}
|
||
/>
|
||
|
||
{/* Strava export — preview duplicates, then import */}
|
||
<StravaExportZone />
|
||
|
||
{/* Ongoing FIT files */}
|
||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<span className="text-2xl">🔄</span>
|
||
<div>
|
||
<h3 className="font-semibold text-white">Ongoing sync</h3>
|
||
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-3 text-xs text-gray-500">
|
||
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
|
||
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
|
||
GARMIN/Activity/*.fit
|
||
</code>
|
||
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
|
||
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
|
||
{`# Example: auto-upload new FIT files
|
||
inotifywait -m ~/Garmin/Activity/ -e create \\
|
||
--format '%f' | while read file; do
|
||
curl -X POST /api/upload/activity \\
|
||
-H "Authorization: Bearer TOKEN" \\
|
||
-F "file=@$file"
|
||
done`}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|