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:
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../hooks/useAuth'
|
import { useAuthStore } from '../../hooks/useAuth'
|
||||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
|
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
||||||
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
||||||
{ to: '/health', label: 'Health', icon: '❤️' },
|
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
||||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
||||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||||
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||||
@@ -17,14 +17,19 @@ const nav = [
|
|||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
|
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
|
||||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startPolling()
|
startPolling()
|
||||||
return () => stopPolling()
|
return () => stopPolling()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Close the mobile "More" sheet on navigation
|
||||||
|
useEffect(() => { setMoreOpen(false) }, [location.pathname])
|
||||||
|
|
||||||
const toggleCollapsed = () => {
|
const toggleCollapsed = () => {
|
||||||
setCollapsed(c => {
|
setCollapsed(c => {
|
||||||
const next = !c
|
const next = !c
|
||||||
@@ -39,10 +44,28 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = user?.is_admin ? 'Administrator' : 'Member'
|
const role = user?.is_admin ? 'Administrator' : 'Member'
|
||||||
|
const visibleNav = nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin)
|
||||||
|
const primaryNav = visibleNav.filter(i => i.mobilePrimary)
|
||||||
|
const moreNav = visibleNav.filter(i => !i.mobilePrimary)
|
||||||
|
const moreActive = moreNav.some(i => location.pathname.startsWith(i.to))
|
||||||
|
|
||||||
|
const syncProgress = (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-blue-400">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
Garmin sync
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||||
|
style={{ width: `${syncProgressPct(status)}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-gray-950">
|
<div className="flex flex-col md:flex-row h-dvh overflow-hidden bg-gray-950">
|
||||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}>
|
<aside className={`${collapsed ? 'w-16' : 'w-56'} hidden md:flex flex-shrink-0 bg-gray-900 border-r border-gray-800 flex-col transition-[width] duration-200`}>
|
||||||
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||||
@@ -57,7 +80,7 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-4 overflow-y-auto">
|
<nav className="flex-1 py-4 overflow-y-auto">
|
||||||
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
|
{visibleNav.map(({ to, label, icon, exact }) => (
|
||||||
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
|
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
|
`flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
|
||||||
@@ -73,16 +96,8 @@ export default function Layout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{inProgress && !collapsed && (
|
{inProgress && !collapsed && (
|
||||||
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
|
<div className="px-4 py-3 border-t border-gray-800">
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-400">
|
{syncProgress}
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
|
||||||
Garmin sync
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
|
||||||
style={{ width: `${syncProgressPct(status)}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inProgress && collapsed && (
|
{inProgress && collapsed && (
|
||||||
@@ -125,9 +140,97 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile top header */}
|
||||||
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
|
||||||
|
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||||
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{inProgress && (
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
||||||
|
title={`Garmin sync: ${status || 'starting…'}`} />
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<span className="w-8 h-8 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
|
||||||
|
{user.username?.[0] || '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile bottom tab bar — z above Leaflet controls (~1000) */}
|
||||||
|
<nav className="md:hidden relative z-[1100] flex items-stretch bg-gray-900 border-t border-gray-800 pb-[env(safe-area-inset-bottom)]">
|
||||||
|
{primaryNav.map(({ to, label, icon, exact }) => (
|
||||||
|
<NavLink key={to} to={to} end={exact}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
|
||||||
|
isActive ? 'text-blue-400' : 'text-gray-500'
|
||||||
|
}`
|
||||||
|
}>
|
||||||
|
<span className="text-lg leading-none">{icon}</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setMoreOpen(true)}
|
||||||
|
className={`relative flex-1 flex flex-col items-center gap-0.5 py-2 text-[10px] transition-colors ${
|
||||||
|
moreActive ? 'text-blue-400' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
<span className="text-lg leading-none">☰</span>
|
||||||
|
More
|
||||||
|
{inProgress && (
|
||||||
|
<span className="absolute top-1.5 right-1/2 translate-x-4 inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile "More" slide-up sheet */}
|
||||||
|
{moreOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-[1200]">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={() => setMoreOpen(false)} />
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-gray-900 rounded-t-2xl border-t border-gray-800 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<span className="w-10 h-1 rounded-full bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
{moreNav.map(({ to, label, icon, exact }) => (
|
||||||
|
<NavLink key={to} to={to} end={exact} onClick={() => setMoreOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-2 py-3 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive ? 'bg-blue-600/20 text-blue-400' : 'text-gray-300 hover:bg-gray-800'
|
||||||
|
}`
|
||||||
|
}>
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{inProgress && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-800">
|
||||||
|
{syncProgress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-800 flex items-center gap-2.5">
|
||||||
|
<span className="w-8 h-8 flex-shrink-0 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
|
||||||
|
{user.username?.[0] || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{user.username}</p>
|
||||||
|
<p className={`text-xs ${user.is_admin ? 'text-amber-400' : 'text-gray-500'}`}>{role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleLogout}
|
||||||
|
className="text-sm text-gray-400 hover:text-red-400 transition-colors px-2 py-1">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useMediaQuery(query) {
|
||||||
|
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query)
|
||||||
|
const onChange = e => setMatches(e.matches)
|
||||||
|
mql.addEventListener('change', onChange)
|
||||||
|
setMatches(mql.matches)
|
||||||
|
return () => mql.removeEventListener('change', onChange)
|
||||||
|
}, [query])
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches Tailwind's md breakpoint — keep CSS (md:) and JS forks in agreement.
|
||||||
|
export const useIsMobile = () => !useMediaQuery('(min-width: 768px)')
|
||||||
@@ -41,7 +41,7 @@ export default function ActivitiesPage() {
|
|||||||
const clearDateFilter = () => navigate('/activities')
|
const clearDateFilter = () => navigate('/activities')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
<Link
|
<Link
|
||||||
@@ -54,7 +54,7 @@ export default function ActivitiesPage() {
|
|||||||
|
|
||||||
{/* YTD stats */}
|
{/* YTD stats */}
|
||||||
{ytdStats && (
|
{ytdStats && (
|
||||||
<div className="flex gap-4 mb-4 text-sm">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
||||||
{ytdStats.running_km > 0 && (
|
{ytdStats.running_km > 0 && (
|
||||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||||
)}
|
)}
|
||||||
@@ -100,7 +100,7 @@ export default function ActivitiesPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={activity.id}
|
key={activity.id}
|
||||||
to={`/activities/${activity.id}`}
|
to={`/activities/${activity.id}`}
|
||||||
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group"
|
className="flex items-center gap-3 p-3 sm:gap-4 sm:p-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl transition-all group"
|
||||||
>
|
>
|
||||||
{/* Sport indicator */}
|
{/* Sport indicator */}
|
||||||
<div
|
<div
|
||||||
@@ -116,6 +116,10 @@ export default function ActivitiesPage() {
|
|||||||
{activity.name}
|
{activity.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||||
|
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||||||
|
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||||||
|
{formatDistance(activity.distance_m)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
|
|||||||
@@ -120,20 +120,20 @@ export default function ActivityDetailPage() {
|
|||||||
if (!activity) return null
|
if (!activity) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
||||||
<h1 className="text-2xl font-bold text-white">{activity.name}</h1>
|
<h1 className="text-2xl font-bold text-white break-words min-w-0">{activity.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats — all on one row */}
|
{/* Stats — all on one row */}
|
||||||
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
|
||||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
||||||
sub={activity.moving_time_s ? 'moving' : undefined} />
|
sub={activity.moving_time_s ? 'moving' : undefined} />
|
||||||
@@ -164,8 +164,8 @@ export default function ActivityDetailPage() {
|
|||||||
{activity.polyline && activity.distance_m > 0 ? (
|
{activity.polyline && activity.distance_m > 0 ? (
|
||||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||||
{/* Map toolbar */}
|
{/* Map toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
<div className="flex flex-wrap items-center justify-between gap-y-2 px-4 py-2 border-b border-gray-800">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Map style:</span>
|
<span className="text-xs text-gray-500">Map style:</span>
|
||||||
{['dark', 'street', 'satellite'].map(t => (
|
{['dark', 'street', 'satellite'].map(t => (
|
||||||
<button
|
<button
|
||||||
@@ -202,7 +202,8 @@ export default function ActivityDetailPage() {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="text-xs text-gray-500 ml-2">Height:</span>
|
<div className="hidden sm:flex items-center gap-2 ml-2">
|
||||||
|
<span className="text-xs text-gray-500">Height:</span>
|
||||||
{[280, 420, 560].map(h => (
|
{[280, 420, 560].map(h => (
|
||||||
<button
|
<button
|
||||||
key={h}
|
key={h}
|
||||||
@@ -216,6 +217,7 @@ export default function ActivityDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{segCreate && (
|
{segCreate && (
|
||||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
|
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
|
||||||
<span className="text-green-400">
|
<span className="text-green-400">
|
||||||
@@ -272,7 +274,7 @@ export default function ActivityDetailPage() {
|
|||||||
|
|
||||||
{/* Metric timeline */}
|
{/* Metric timeline */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'react-grid-layout/css/styles.css'
|
|||||||
import 'react-resizable/css/styles.css'
|
import 'react-resizable/css/styles.css'
|
||||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
@@ -513,6 +514,7 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Layout state ──────────────────────────────────────────────────────────
|
// ── Layout state ──────────────────────────────────────────────────────────
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [layout, setLayout] = useState(() => buildLayout(null))
|
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) }
|
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 finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
|
||||||
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
|
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 presentIds = new Set(layout.map(l => l.i))
|
||||||
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<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-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -607,6 +646,7 @@ export default function DashboardPage() {
|
|||||||
{editMode && (
|
{editMode && (
|
||||||
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
|
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
|
||||||
)}
|
)}
|
||||||
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
|
||||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
|
||||||
@@ -614,6 +654,7 @@ export default function DashboardPage() {
|
|||||||
}`}>
|
}`}>
|
||||||
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
{editMode ? '✓ Done' : '✎ Edit dashboard'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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
|
<Grid
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={layout}
|
layout={layout}
|
||||||
@@ -647,6 +689,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<span className="text-4xl font-bold text-white tracking-tight">
|
<span className="text-4xl font-bold text-white tracking-tight">
|
||||||
{formatSleep(day.sleep_duration_s)}
|
{formatSleep(day.sleep_duration_s)}
|
||||||
</span>
|
</span>
|
||||||
@@ -995,7 +995,7 @@ export default function HealthPage() {
|
|||||||
const selDateForCharts = selectedDay?.date
|
const selDateForCharts = selectedDay?.date
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-4 md:p-6 space-y-6 md:space-y-8">
|
||||||
<h1 className="text-2xl font-bold text-white">Health</h1>
|
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||||
|
|
||||||
<DailySnapshot
|
<DailySnapshot
|
||||||
@@ -1024,7 +1024,7 @@ export default function HealthPage() {
|
|||||||
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
||||||
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{RANGES.map(({ label, days }, i) => {
|
{RANGES.map(({ label, days }, i) => {
|
||||||
const disabled = i > maxEnabledRangeIdx
|
const disabled = i > maxEnabledRangeIdx
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
<div className="min-h-dvh bg-gray-950 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-white">
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function ProfilePage() {
|
|||||||
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl space-y-6">
|
<div className="p-4 md:p-6 max-w-2xl space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||||
|
|
||||||
{/* HR & Measurements */}
|
{/* HR & Measurements */}
|
||||||
@@ -205,7 +205,7 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||||
@@ -235,7 +235,7 @@ export default function ProfilePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-3 border-t border-gray-800">
|
||||||
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
|
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
|
||||||
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
|
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
|
||||||
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function DistancePRs() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{SPORTS.map(s => (
|
{SPORTS.map(s => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
@@ -69,6 +69,7 @@ function DistancePRs() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
@@ -108,6 +109,7 @@ function DistancePRs() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
{selectedDistance && history ? (
|
{selectedDistance && history ? (
|
||||||
@@ -169,6 +171,7 @@ function RouteRecords() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
@@ -176,8 +179,8 @@ function RouteRecords() {
|
|||||||
<th className="text-left px-3 py-3 font-medium">Route</th>
|
<th className="text-left px-3 py-3 font-medium">Route</th>
|
||||||
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||||
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||||
<th className="text-right px-3 py-3 font-medium">Pace</th>
|
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Pace</th>
|
||||||
<th className="text-right px-3 py-3 font-medium">Date</th>
|
<th className="hidden sm:table-cell text-right px-3 py-3 font-medium">Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -200,10 +203,10 @@ function RouteRecords() {
|
|||||||
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
{formatDuration(rec.duration_s)}
|
{formatDuration(rec.duration_s)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatDate(rec.start_time)}
|
{formatDate(rec.start_time)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -211,6 +214,7 @@ function RouteRecords() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +259,7 @@ function SegmentRecords() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
@@ -303,6 +308,7 @@ function SegmentRecords() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +316,7 @@ export default function RecordsPage() {
|
|||||||
const [tab, setTab] = useState('Distance PRs')
|
const [tab, setTab] = useState('Distance PRs')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-white">Records</h1>
|
<h1 className="text-2xl font-bold text-white">Records</h1>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex gap-4 items-start min-w-0">
|
<div className="flex flex-col sm:flex-row gap-4 items-start min-w-0 w-full sm:w-auto">
|
||||||
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
<div className="w-full sm:w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
|
||||||
{selected.reference_polyline
|
{selected.reference_polyline
|
||||||
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
|
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
|
||||||
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
|
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
|
||||||
@@ -262,7 +262,7 @@ export default function RoutesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
|
|||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
||||||
<p className="text-gray-500 text-sm mt-1">
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-3xl space-y-6">
|
<div className="p-4 md:p-6 max-w-3xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Users</h1>
|
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
@@ -43,6 +43,7 @@ export default function UsersPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
<p className="p-5 text-sm text-gray-500">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||||
@@ -91,6 +92,7 @@ export default function UsersPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://backend:8000',
|
// 'backend' only resolves inside docker compose; override for host-side dev
|
||||||
|
target: process.env.VITE_PROXY_TARGET || 'http://backend:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user