Mobile-responsive UI: bottom tab nav, stacked dashboard, page fixes for phones
- Layout: sidebar hidden below md; mobile top header + bottom tab bar (Dashboard/Activities/Health/Routes) with a More slide-up sheet holding Records/Import/Profile/Users, Garmin sync progress and sign-out - Dashboard: single-column widget stack on phones ordered from the saved desktop layout (stat cards pair into 2-col grid); drag-edit stays desktop-only; grid conditionally mounted so WidthProvider never measures a hidden container - New useMediaQuery/useIsMobile hook (matchMedia, md breakpoint) - ActivityDetail: 2-col stats on phones, wrapping map toolbar, Height control hidden on small screens - Activities: compact distance/time/pace line on mobile rows - Records/Users: horizontally scrollable tables; route records hide Pace/Date columns below sm - Profile forms single-column on phones; Routes expanded card stacks; Health flex-wrap fixes; p-4 md:p-6 page padding; h-dvh for mobile browser chrome - vite.config: VITE_PROXY_TARGET override for host-side dev Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import 'react-grid-layout/css/styles.css'
|
||||
import 'react-resizable/css/styles.css'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import {
|
||||
@@ -513,6 +514,7 @@ export default function DashboardPage() {
|
||||
})
|
||||
|
||||
// ── Layout state ──────────────────────────────────────────────────────────
|
||||
const isMobile = useIsMobile()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [layout, setLayout] = useState(() => buildLayout(null))
|
||||
@@ -554,6 +556,11 @@ export default function DashboardPage() {
|
||||
}
|
||||
const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) }
|
||||
|
||||
// Editing is desktop-only; drop out of edit mode if the viewport shrinks mid-edit.
|
||||
useEffect(() => {
|
||||
if (isMobile && editMode) { setEditMode(false); setAddOpen(false) }
|
||||
}, [isMobile])
|
||||
|
||||
const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
|
||||
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
|
||||
|
||||
@@ -579,8 +586,40 @@ export default function DashboardPage() {
|
||||
const presentIds = new Set(layout.map(l => l.i))
|
||||
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
|
||||
|
||||
// Single-column stack for phones: saved desktop layout read top-to-bottom,
|
||||
// left-to-right; consecutive stat cards pair up into a 2-column grid.
|
||||
const renderMobileStack = () => {
|
||||
const sorted = [...layout].filter(l => WIDGETS[l.i]).sort((a, b) => a.y - b.y || a.x - b.x)
|
||||
const groups = []
|
||||
for (const l of sorted) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (STAT_DEFS[l.i] && last?.stats) last.items.push(l)
|
||||
else groups.push({ stats: !!STAT_DEFS[l.i], items: [l] })
|
||||
}
|
||||
// Content-driven widgets size themselves; chart widgets need the explicit
|
||||
// height the grid normally provides (rowHeight=80, margin=16) or their
|
||||
// ResponsiveContainers collapse.
|
||||
const autoHeight = new Set(['sleepDetail', 'prs'])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groups.map((g, idx) =>
|
||||
g.stats ? (
|
||||
<div key={idx} className="grid grid-cols-2 gap-3">
|
||||
{g.items.map(l => <div key={l.i}>{renderWidget(l.i)}</div>)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={g.items[0].i}
|
||||
style={autoHeight.has(g.items[0].i) ? undefined : { height: g.items[0].h * 80 + (g.items[0].h - 1) * 16 }}>
|
||||
{renderWidget(g.items[0].i)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -607,13 +646,15 @@ export default function DashboardPage() {
|
||||
{editMode && (
|
||||
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
||||
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
|
||||
}`}>
|
||||
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
||||
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
|
||||
}`}>
|
||||
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
||||
</button>
|
||||
)}
|
||||
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,6 +663,7 @@ export default function DashboardPage() {
|
||||
<p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with ✕. Add widgets from the menu. Changes save automatically.</p>
|
||||
)}
|
||||
|
||||
{isMobile ? renderMobileStack() : (
|
||||
<Grid
|
||||
className="layout"
|
||||
layout={layout}
|
||||
@@ -647,6 +689,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user