Initial Commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: routeActivities } = useQuery({
|
||||
queryKey: ['route-activities', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const { data: segments } = useQuery({
|
||||
queryKey: ['route-segments', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const createRoute = useMutation({
|
||||
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setShowCreate(false)
|
||||
setNewRoute({ name: '', activity_id: '' })
|
||||
},
|
||||
})
|
||||
|
||||
const fastest = routeActivities?.[0]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
+ New route
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create route modal */}
|
||||
{showCreate && (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||
<input
|
||||
value={newRoute.name}
|
||||
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Morning park loop"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRoute.activity_id}
|
||||
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Activity ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||
disabled={!newRoute.name || !newRoute.activity_id}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Route list */}
|
||||
<div className="space-y-2">
|
||||
{routes?.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
</div>
|
||||
)}
|
||||
{routes?.map(route => (
|
||||
<button
|
||||
key={route.id}
|
||||
onClick={() => setSelected(route)}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||
selected?.id === route.id
|
||||
? 'bg-blue-900/20 border-blue-700'
|
||||
: 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-white">{route.name}</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>{formatDistance(route.distance_m)}</span>
|
||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||
<span>{formatDate(route.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Route detail */}
|
||||
{selected && (
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
|
||||
{selected.description && (
|
||||
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
|
||||
)}
|
||||
|
||||
{/* CR */}
|
||||
{fastest && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-yellow-600 mb-1">Course record</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xl font-bold text-yellow-400">
|
||||
{formatDuration(fastest.duration_s)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All runs on route */}
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||
All runs ({routeActivities?.length ?? 0})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{routeActivities?.map((act, i) => (
|
||||
<div
|
||||
key={act.id}
|
||||
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
|
||||
>
|
||||
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
||||
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||||
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||||
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||
{act.avg_heart_rate && (
|
||||
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||
)}
|
||||
{i === 0 && (
|
||||
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">
|
||||
CR
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segments */}
|
||||
{segments && segments.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
|
||||
<div className="space-y-2">
|
||||
{segments.map(seg => (
|
||||
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{seg.name}</p>
|
||||
{seg.description && (
|
||||
<p className="text-xs text-gray-500">{seg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 text-right">
|
||||
<p>{formatDistance(seg.start_distance_m)} → {formatDistance(seg.end_distance_m)}</p>
|
||||
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user