179 lines
6.5 KiB
React
179 lines
6.5 KiB
React
import { useState, useCallback } from 'react'
|
|
import { useDropzone } from 'react-dropzone'
|
|
import { useMutation } from '@tanstack/react-query'
|
|
import api from '../utils/api'
|
|
|
|
function UploadZone({ title, description, accept, endpoint, icon }) {
|
|
const [tasks, setTasks] = useState([])
|
|
|
|
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) => {
|
|
setTasks(t => [...t, { ...data, status: 'queued' }])
|
|
},
|
|
})
|
|
|
|
const onDrop = useCallback((accepted) => {
|
|
accepted.forEach(file => upload.mutate(file))
|
|
}, [upload])
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept,
|
|
multiple: true,
|
|
})
|
|
|
|
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="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
|
<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>
|
|
)}
|
|
<span className="ml-2 text-green-400">✓ Queued</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function UploadPage() {
|
|
return (
|
|
<div className="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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
{/* Single FIT/GPX */}
|
|
<UploadZone
|
|
title="Single activity"
|
|
description="Upload a .fit or .gpx file"
|
|
icon="🏃"
|
|
endpoint="/upload/activity"
|
|
accept={{
|
|
'application/octet-stream': ['.fit'],
|
|
'application/gpx+xml': ['.gpx'],
|
|
'text/xml': ['.gpx'],
|
|
}}
|
|
/>
|
|
|
|
{/* 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 */}
|
|
<UploadZone
|
|
title="Strava bulk export"
|
|
description="Upload your Strava archive ZIP"
|
|
icon="🚴"
|
|
endpoint="/upload/strava-export"
|
|
accept={{ 'application/zip': ['.zip'] }}
|
|
/>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|