Initial Commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user