feat: rename segments + create routes from an activity (detail page & dashboard recent activities)
This commit is contained in:
@@ -75,6 +75,8 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
const qc = useQueryClient()
|
||||
const unit = useUnit()
|
||||
const [open, setOpen] = useState(null)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!confirm('Delete this segment?')) return
|
||||
@@ -82,6 +84,20 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
|
||||
const startRename = (seg) => {
|
||||
setEditingId(seg.segment_id)
|
||||
setEditName(seg.name)
|
||||
}
|
||||
const saveRename = async (id) => {
|
||||
const next = editName.trim()
|
||||
if (next) {
|
||||
await api.patch(`/segments/${id}`, { name: next })
|
||||
qc.invalidateQueries({ queryKey: ['activity-segments', activityId] })
|
||||
qc.invalidateQueries({ queryKey: ['segment', id] })
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -105,14 +121,28 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="py-2">
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
||||
className="text-left text-gray-300 hover:text-white"
|
||||
>
|
||||
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
||||
{seg.name}
|
||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
|
||||
</button>
|
||||
{editingId === seg.segment_id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveRename(seg.segment_id)
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onBlur={() => saveRename(seg.segment_id)}
|
||||
className="bg-gray-800 text-white rounded px-2 py-0.5 border border-gray-600 focus:border-blue-500 focus:outline-none text-sm"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
|
||||
className="text-left text-gray-300 hover:text-white"
|
||||
>
|
||||
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
||||
{seg.name}
|
||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||
{formatDuration(seg.duration_s)}
|
||||
@@ -127,7 +157,8 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
||||
: <span className="text-gray-700">--</span>}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => startRename(seg)} className="text-gray-700 hover:text-white text-xs mr-2" title="Rename segment">✎</button>
|
||||
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import api from '../utils/api'
|
||||
@@ -41,6 +41,9 @@ export default function ActivityDetailPage() {
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameInput, setNameInput] = useState('')
|
||||
const [nameError, setNameError] = useState('')
|
||||
const [routeCreate, setRouteCreate] = useState(false)
|
||||
const [routeName, setRouteName] = useState('')
|
||||
const [routeError, setRouteError] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: activity, isLoading } = useQuery({
|
||||
@@ -110,6 +113,20 @@ export default function ActivityDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const createRoute = async () => {
|
||||
const name = routeName.trim()
|
||||
if (!name) { setRouteError('Name cannot be empty'); return }
|
||||
setRouteError('')
|
||||
try {
|
||||
await api.post('/routes/', { name, activity_id: Number(id) })
|
||||
setRouteCreate(false); setRouteName('')
|
||||
qc.invalidateQueries({ queryKey: ['activity', id] })
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
} catch (e) {
|
||||
setRouteError(e.response?.data?.detail || 'Failed to create route')
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
setNameInput(activity.name)
|
||||
setNameError('')
|
||||
@@ -191,6 +208,40 @@ export default function ActivityDetailPage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
||||
{/* Named route link / create-route control */}
|
||||
{activity.named_route_id ? (
|
||||
<p className="text-sm text-blue-400 mt-1">
|
||||
📍 <Link to="/routes" className="hover:underline">{activity.named_route_name}</Link>
|
||||
</p>
|
||||
) : activity.polyline && activity.distance_m > 0 ? (
|
||||
routeCreate ? (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={routeName}
|
||||
onChange={e => setRouteName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') createRoute()
|
||||
if (e.key === 'Escape') { setRouteCreate(false); setRouteError('') }
|
||||
}}
|
||||
placeholder="Route name"
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button onClick={createRoute} disabled={!routeName.trim()}
|
||||
className="text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">Create route</button>
|
||||
<button onClick={() => { setRouteCreate(false); setRouteError('') }}
|
||||
className="text-sm text-gray-400 hover:text-white px-1">Cancel</button>
|
||||
{routeError && <span className="text-xs text-red-400">{routeError}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setRouteName(activity.name); setRouteCreate(true); setRouteError('') }}
|
||||
className="text-sm text-gray-500 hover:text-blue-400 mt-1 transition-colors"
|
||||
>
|
||||
+ Create route from this activity
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -371,6 +371,22 @@ function FeaturedActivity({ activity, segments }) {
|
||||
|
||||
function RecentActivities({ activities }) {
|
||||
const unit = useUnit()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const createRoute = async (activity, e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const name = window.prompt('Name for the new route:', activity.name)
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
await api.post('/routes/', { name: name.trim(), activity_id: activity.id })
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
qc.invalidateQueries({ queryKey: ['activities-recent'] })
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.detail || 'Failed to create route')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Recent activities" viewHref="/activities">
|
||||
{activities?.length ? (
|
||||
@@ -380,7 +396,7 @@ function RecentActivities({ activities }) {
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{activities.slice(0, 6).map(activity => (
|
||||
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-3 flex-1 min-h-0 overflow-hidden px-2 border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 rounded-lg transition-colors">
|
||||
className="group flex items-center gap-3 flex-1 min-h-0 overflow-hidden px-2 border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 rounded-lg transition-colors">
|
||||
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
@@ -389,6 +405,15 @@ function RecentActivities({ activities }) {
|
||||
)}
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
{!activity.named_route_id && activity.polyline && activity.distance_m > 0 && (
|
||||
<button
|
||||
onClick={e => createRoute(activity, e)}
|
||||
title="Create route from this activity"
|
||||
className="shrink-0 text-gray-600 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity text-sm px-1"
|
||||
>
|
||||
📍+
|
||||
</button>
|
||||
)}
|
||||
<div className="text-right text-sm shrink-0">
|
||||
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
|
||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
|
||||
Reference in New Issue
Block a user