feat(frontend): Kinetic Precision design system — new lime theme, glassmorphism, redesigned pages
- New design system: Stitch (kinetic-precision.css) with lime (#cafd00) accent - New Google Fonts: Lexend, Plus Jakarta Sans, Space Grotesk - New page: BenchmarksPage with strength/endurance/body tracking - Redesigned: Dashboard, ProgressPage, WorkoutPage, LoginPage + LoginPage.css - Add shared glassmorphism nav, kinetic buttons, intensity indicators - Build: 265KB JS / 88KB CSS / 2.54s (clean)
This commit is contained in:
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>Gravl - Träning</title>
|
||||
<script type="module" crossorigin src="/assets/index-aU0r4U2I.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KaIXgP3q.css">
|
||||
<script type="module" crossorigin src="/assets/index-n3qbre_V.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKolXSJV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+9
-1
@@ -20,12 +20,20 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# index.html — never cache so new deploys load fresh
|
||||
location = /index.html {
|
||||
try_files $uri /index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
# Cache static assets (fingerprinted filenames, safe to cache long)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
@@ -7,6 +7,7 @@ import WorkoutPage from './pages/WorkoutPage'
|
||||
import WorkoutSelectPage from './pages/WorkoutSelectPage'
|
||||
import ChatOnboarding from './pages/ChatOnboarding'
|
||||
import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage'
|
||||
import BenchmarksPage from './pages/BenchmarksPage'
|
||||
import './App.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
@@ -150,6 +151,11 @@ function App() {
|
||||
return <ExerciseEncyclopediaPage onBack={() => setView('dashboard')} />
|
||||
}
|
||||
|
||||
// Benchmarks page
|
||||
if (view === 'benchmarks') {
|
||||
return <BenchmarksPage onBack={() => setView('dashboard')} />
|
||||
}
|
||||
|
||||
// Workout select page
|
||||
if (view === 'select-workout') {
|
||||
return (
|
||||
|
||||
+66
-65
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -5,60 +7,61 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Dark fitness palette - refined */
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #0d0d14;
|
||||
--bg-tertiary: #12121a;
|
||||
--bg-card: #16161f;
|
||||
--bg-card-hover: #1c1c28;
|
||||
--bg-elevated: #1a1a24;
|
||||
--bg: #0a0a0f;
|
||||
/* Kinetic Precision - Stitch Design System */
|
||||
--bg-primary: #0e0e0e;
|
||||
--bg-secondary: #131313;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-card-hover: #20201f;
|
||||
--bg-elevated: #20201f;
|
||||
--bg: #0e0e0e;
|
||||
|
||||
/* Text colors - better hierarchy */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--text-tertiary: #52525b;
|
||||
--text-secondary: #adaaaa;
|
||||
--text-muted: #767575;
|
||||
--text-tertiary: #484847;
|
||||
--text: #ffffff;
|
||||
|
||||
/* Accent - refined energetic coral */
|
||||
--accent: #ff6b4a;
|
||||
--accent-hover: #ff8066;
|
||||
--accent-subtle: rgba(255, 107, 74, 0.15);
|
||||
--accent-glow: rgba(255, 107, 74, 0.25);
|
||||
/* Primary: Electric Lime */
|
||||
--accent: #cafd00;
|
||||
--accent-hover: #beee00;
|
||||
--accent-subtle: rgba(202, 253, 0, 0.12);
|
||||
--accent-glow: rgba(202, 253, 0, 0.25);
|
||||
--accent-on: #516700;
|
||||
|
||||
/* Status colors - refined */
|
||||
--success: #22c55e;
|
||||
--success-subtle: rgba(34, 197, 94, 0.15);
|
||||
--warning: #f59e0b;
|
||||
--warning-subtle: rgba(245, 158, 11, 0.15);
|
||||
--error: #ef4444;
|
||||
--error-subtle: rgba(239, 68, 68, 0.15);
|
||||
/* Secondary: Orange */
|
||||
--secondary: #ff7440;
|
||||
--secondary-hover: #ff8c5a;
|
||||
--secondary-subtle: rgba(255, 116, 64, 0.12);
|
||||
--secondary-glow: rgba(255, 116, 64, 0.25);
|
||||
|
||||
/* Borders - refined */
|
||||
--border: #1f1f2a;
|
||||
--border-hover: #2a2a38;
|
||||
--border-accent: var(--accent-subtle);
|
||||
--success: #f3ffca;
|
||||
--success-subtle: rgba(243, 255, 202, 0.12);
|
||||
--warning: #ff7440;
|
||||
--warning-subtle: rgba(255, 116, 64, 0.12);
|
||||
--error: #ff7351;
|
||||
--error-subtle: rgba(255, 115, 81, 0.15);
|
||||
|
||||
/* Shadows - key for enterprise feel */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
--border: #1f1f1f;
|
||||
--border-hover: #262626;
|
||||
--border-accent: rgba(202, 253, 0, 0.2);
|
||||
|
||||
/* Workout type colors - refined */
|
||||
--workout-push: #ef4444;
|
||||
--workout-pull: #3b82f6;
|
||||
--workout-legs: #22c55e;
|
||||
--workout-shoulders: #f59e0b;
|
||||
--workout-upper: #8b5cf6;
|
||||
--workout-lower: #06b6d4;
|
||||
--workout-default: #ff6b4a;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-glow: 0 0 20px rgba(202, 253, 0, 0.3);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--workout-push: #ff7440;
|
||||
--workout-pull: #f3ffca;
|
||||
--workout-legs: #cafd00;
|
||||
--workout-shoulders: #ff7440;
|
||||
--workout-upper: #f3ffca;
|
||||
--workout-lower: #beee00;
|
||||
--workout-default: #cafd00;
|
||||
|
||||
/* Typography scale */
|
||||
--font-xs: 0.75rem;
|
||||
--font-sm: 0.875rem;
|
||||
--font-base: 1rem;
|
||||
@@ -67,7 +70,6 @@
|
||||
--font-2xl: 1.5rem;
|
||||
--font-3xl: 2rem;
|
||||
|
||||
/* Spacing scale */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
@@ -78,22 +80,20 @@
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 18px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 10px;
|
||||
--radius-2xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -103,6 +103,7 @@ html, body {
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -277,13 +278,13 @@ input {
|
||||
|
||||
.auth-card button[type="submit"] {
|
||||
padding: var(--space-4);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
color: var(--accent-on);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-base);
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -297,14 +298,14 @@ input {
|
||||
}
|
||||
|
||||
.auth-card button[type="submit"]:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
|
||||
box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4);
|
||||
}
|
||||
|
||||
.auth-card button[type="submit"]:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
|
||||
box-shadow: 0 2px 8px rgba(202, 253, 0, 0.3);
|
||||
}
|
||||
|
||||
.auth-card button:disabled {
|
||||
@@ -802,17 +803,17 @@ input {
|
||||
}
|
||||
|
||||
.next-btn, .finish-btn {
|
||||
background: var(--accent) !important;
|
||||
color: white !important;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%) !important;
|
||||
color: var(--accent-on) !important;
|
||||
font-weight: 600;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3);
|
||||
}
|
||||
|
||||
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover) !important;
|
||||
background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%) !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
|
||||
box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Icon } from '../components/Icons'
|
||||
import '../styles/kinetic-precision.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
// Placeholder data shown when API is unavailable
|
||||
const PLACEHOLDER_DATA = {
|
||||
strength: [
|
||||
{
|
||||
id: 'deadlift',
|
||||
name: 'Marklyft',
|
||||
current: 140,
|
||||
goal: 180,
|
||||
unit: 'kg',
|
||||
intensity: 'lime',
|
||||
category: 'Styrka',
|
||||
},
|
||||
{
|
||||
id: 'squat',
|
||||
name: 'Knäböj',
|
||||
current: 110,
|
||||
goal: 150,
|
||||
unit: 'kg',
|
||||
intensity: 'lime',
|
||||
category: 'Styrka',
|
||||
},
|
||||
{
|
||||
id: 'bench',
|
||||
name: 'Bänkpress',
|
||||
current: 90,
|
||||
goal: 120,
|
||||
unit: 'kg',
|
||||
intensity: 'lime',
|
||||
category: 'Styrka',
|
||||
},
|
||||
],
|
||||
endurance: [
|
||||
{
|
||||
id: 'fivek',
|
||||
name: '5K Löptid',
|
||||
current: '24:30',
|
||||
currentRaw: 24.5,
|
||||
goal: 22,
|
||||
unit: 'min',
|
||||
intensity: 'orange',
|
||||
lowerIsBetter: true,
|
||||
category: 'Kondition',
|
||||
},
|
||||
{
|
||||
id: 'vo2max',
|
||||
name: 'VO2 Max',
|
||||
current: 48,
|
||||
goal: 55,
|
||||
unit: 'ml/kg/min',
|
||||
intensity: 'orange',
|
||||
lowerIsBetter: false,
|
||||
category: 'Kondition',
|
||||
},
|
||||
],
|
||||
body: [
|
||||
{
|
||||
id: 'mass',
|
||||
name: 'Kroppsvikt',
|
||||
current: 82,
|
||||
goal: 80,
|
||||
unit: 'kg',
|
||||
intensity: 'lime',
|
||||
lowerIsBetter: true,
|
||||
category: 'Kropp',
|
||||
},
|
||||
{
|
||||
id: 'bodyfat',
|
||||
name: 'Kroppsfett',
|
||||
current: 16,
|
||||
goal: 12,
|
||||
unit: '%',
|
||||
intensity: 'orange',
|
||||
lowerIsBetter: true,
|
||||
category: 'Kropp',
|
||||
},
|
||||
{
|
||||
id: 'muscle',
|
||||
name: 'Muskelmassa',
|
||||
current: 68,
|
||||
goal: 72,
|
||||
unit: 'kg',
|
||||
intensity: 'lime',
|
||||
lowerIsBetter: false,
|
||||
category: 'Kropp',
|
||||
},
|
||||
],
|
||||
goals: [
|
||||
{ id: 1, text: 'Marklyft 180 kg', progress: 78, type: 'lime' },
|
||||
{ id: 2, text: 'Sänk kroppsfett till 12%', progress: 44, type: 'orange' },
|
||||
{ id: 3, text: '5K under 22 min', progress: 60, type: 'orange' },
|
||||
{ id: 4, text: 'VO2 Max 55', progress: 55, type: 'lime' },
|
||||
],
|
||||
}
|
||||
|
||||
function getProgress(metric) {
|
||||
if (metric.lowerIsBetter) {
|
||||
const rawCurrent = typeof metric.current === 'string' ? metric.currentRaw : metric.current
|
||||
const range = rawCurrent - metric.goal
|
||||
const total = rawCurrent // distance from 0 to current
|
||||
if (total <= 0) return 100
|
||||
return Math.min(100, Math.max(0, Math.round((1 - range / total) * 100)))
|
||||
}
|
||||
if (metric.goal <= 0) return 0
|
||||
return Math.min(100, Math.round((metric.current / metric.goal) * 100))
|
||||
}
|
||||
|
||||
function MetricCard({ metric }) {
|
||||
const progress = getProgress(metric)
|
||||
const isLime = metric.intensity === 'lime'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`benchmark-card intensity-bar-${metric.intensity}`}
|
||||
style={{ paddingLeft: '1.5rem' }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<p className="data-label">{metric.category}</p>
|
||||
<h3 style={{ fontFamily: "'Lexend', sans-serif", fontWeight: 700, fontSize: '1rem', color: '#ffffff', marginTop: '0.125rem' }}>
|
||||
{metric.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="stat-chip">
|
||||
<span className="stat-number" style={{ color: isLime ? '#cafd00' : '#ff7440' }}>
|
||||
{metric.current}
|
||||
</span>
|
||||
<span className="stat-unit">{metric.unit}</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.7rem', color: '#767575', marginTop: '0.125rem' }}>
|
||||
Mål: {metric.goal} {metric.unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className={`progress-bar-fill${isLime ? '' : ' secondary'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.7rem', color: '#767575', marginTop: '0.375rem', textAlign: 'right' }}>
|
||||
{progress}% av mål
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ title }) {
|
||||
return (
|
||||
<div style={{ padding: '0.75rem 0 0.5rem' }}>
|
||||
<h2 style={{
|
||||
fontFamily: "'Lexend', sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: '1.125rem',
|
||||
color: '#ffffff',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GoalCard({ goal }) {
|
||||
const isLime = goal.type === 'lime'
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '8px',
|
||||
padding: '0.875rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: isLime ? 'rgba(202, 253, 0, 0.1)' : 'rgba(255, 116, 64, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={isLime ? '#cafd00' : '#ff7440'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontSize: '0.875rem', fontWeight: 600, color: '#ffffff', marginBottom: '0.375rem' }}>
|
||||
{goal.text}
|
||||
</p>
|
||||
<div className="progress-bar-track" style={{ height: '4px' }}>
|
||||
<div
|
||||
className={`progress-bar-fill${isLime ? '' : ' secondary'}`}
|
||||
style={{ width: `${goal.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: "'Lexend', sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
color: isLime ? '#cafd00' : '#ff7440',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{goal.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BenchmarksPage({ onBack }) {
|
||||
const { user } = useAuth()
|
||||
const userId = user?.id || 1
|
||||
const [data, setData] = useState(PLACEHOLDER_DATA)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [usingPlaceholder, setUsingPlaceholder] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBenchmarks = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/benchmarks?user_id=${userId}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
// Merge API data with placeholder structure if keys exist
|
||||
if (json && (json.strength || json.endurance || json.body || json.goals)) {
|
||||
setData({
|
||||
strength: json.strength || PLACEHOLDER_DATA.strength,
|
||||
endurance: json.endurance || PLACEHOLDER_DATA.endurance,
|
||||
body: json.body || PLACEHOLDER_DATA.body,
|
||||
goals: json.goals || PLACEHOLDER_DATA.goals,
|
||||
})
|
||||
} else {
|
||||
setUsingPlaceholder(true)
|
||||
}
|
||||
} catch {
|
||||
setUsingPlaceholder(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchBenchmarks()
|
||||
}, [userId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0e0e0e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#767575' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto 0.75rem' }} />
|
||||
<p style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>Laddar...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0e0e0e', color: '#ffffff', display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* Header - glassmorphism */}
|
||||
<header className="glass-nav" style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
borderBottom: '1px solid #1f1f1f',
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#adaaaa',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
transition: 'color 150ms ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ffffff'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#adaaaa'}
|
||||
aria-label="Tillbaka"
|
||||
>
|
||||
<Icon name="chevronLeft" size={22} />
|
||||
</button>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{
|
||||
fontFamily: "'Lexend', sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: '1.25rem',
|
||||
color: '#ffffff',
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
Benchmarks
|
||||
</h1>
|
||||
<p style={{
|
||||
fontFamily: "'Space Grotesk', monospace",
|
||||
fontSize: '0.75rem',
|
||||
color: '#767575',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginTop: '0.125rem',
|
||||
}}>
|
||||
Mätpunkter & Mål
|
||||
</p>
|
||||
</div>
|
||||
{usingPlaceholder && (
|
||||
<span className="goal-badge active" style={{ fontSize: '0.65rem' }}>Demo</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main style={{ flex: 1, padding: '1rem 1.25rem 6rem', maxWidth: '640px', width: '100%', margin: '0 auto' }}>
|
||||
|
||||
{/* Summary row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
}}>
|
||||
{[
|
||||
{ label: 'Styrka', value: `${data.strength.length}`, sub: 'övningar' },
|
||||
{ label: 'Kondition', value: `${data.endurance.length}`, sub: 'mätvärden' },
|
||||
{ label: 'Aktiva mål', value: `${data.goals.length}`, sub: 'pågående' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ background: '#1a1a1a', borderRadius: '8px', padding: '0.875rem 0.75rem', textAlign: 'center' }}>
|
||||
<p className="data-label" style={{ marginBottom: '0.25rem' }}>{s.label}</p>
|
||||
<p style={{ fontFamily: "'Lexend', sans-serif", fontWeight: 700, fontSize: '1.5rem', color: '#cafd00', lineHeight: 1 }}>{s.value}</p>
|
||||
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.65rem', color: '#767575', textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: '0.125rem' }}>{s.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Strength */}
|
||||
<section style={{ marginBottom: '1.25rem' }}>
|
||||
<SectionHeader title="Styrka" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{data.strength.map(m => <MetricCard key={m.id} metric={m} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Divider via background shift */}
|
||||
<div className="surface-low" style={{ margin: '0 -1.25rem', padding: '1.25rem 1.25rem' }}>
|
||||
{/* Endurance */}
|
||||
<section style={{ marginBottom: '1.25rem' }}>
|
||||
<SectionHeader title="Kondition" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{data.endurance.map(m => <MetricCard key={m.id} metric={m} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Body composition */}
|
||||
<section>
|
||||
<SectionHeader title="Kroppskomposition" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{data.body.map(m => <MetricCard key={m.id} metric={m} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Active goals */}
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<SectionHeader title="Aktiva mål" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{data.goals.map(g => <GoalCard key={g.id} goal={g} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<nav style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(26, 26, 26, 0.7)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
padding: '0.75rem 1.25rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #cafd00 0%, #beee00 100%)',
|
||||
color: '#516700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '0.625rem 1.5rem',
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 16px rgba(202, 253, 0, 0.3)',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 6px 24px rgba(202, 253, 0, 0.4)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 4px 16px rgba(202, 253, 0, 0.3)' }}
|
||||
>
|
||||
Tillbaka till Dashboard
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BenchmarksPage
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Icon, getActivityIconName } from '../components/Icons'
|
||||
import Logo from '../components/Logo'
|
||||
import '../styles/kinetic-precision.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
@@ -11,7 +12,6 @@ const getCoachGreeting = (user, todayWorkout) => {
|
||||
const name = user?.name?.split(' ')[0] || 'du'
|
||||
|
||||
if (todayWorkout) {
|
||||
// There's a workout today
|
||||
if (hour < 10) {
|
||||
return `Godmorgon ${name}! Idag kör vi ${todayWorkout.name.toLowerCase()}. Redo?`
|
||||
} else if (hour < 14) {
|
||||
@@ -22,7 +22,6 @@ const getCoachGreeting = (user, todayWorkout) => {
|
||||
return `Kvällspass ${name}? ${todayWorkout.name} – perfekt för att avsluta dagen.`
|
||||
}
|
||||
} else {
|
||||
// Rest day
|
||||
if (hour < 10) {
|
||||
return `Godmorgon ${name}! Vilodag idag – perfekt för återhämtning.`
|
||||
} else if (hour < 14) {
|
||||
@@ -35,7 +34,6 @@ const getCoachGreeting = (user, todayWorkout) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Rest day tips
|
||||
const restDayTips = [
|
||||
{ iconName: 'walking', text: 'Promenad' },
|
||||
{ iconName: 'yoga', text: 'Stretching' },
|
||||
@@ -43,15 +41,42 @@ const restDayTips = [
|
||||
{ iconName: 'cycling', text: 'Cykling' },
|
||||
]
|
||||
|
||||
// Get weekday names
|
||||
const weekdays = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
|
||||
|
||||
// Format volume number
|
||||
function formatVolume(kg) {
|
||||
if (kg >= 1000) return `${(kg / 1000).toFixed(1).replace('.0', '')} 000`
|
||||
return `${kg}`
|
||||
}
|
||||
|
||||
// Format session date
|
||||
function formatSessionDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Placeholder recent sessions
|
||||
const PLACEHOLDER_SESSIONS = [
|
||||
{ id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 2 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true },
|
||||
{ id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 4 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false },
|
||||
{ id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 6 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false },
|
||||
]
|
||||
|
||||
const PLACEHOLDER_MONTHLY = {
|
||||
stronger_pct: 15,
|
||||
streak: 14,
|
||||
total_volume: 124500,
|
||||
}
|
||||
|
||||
function Dashboard({ onStartWorkout, onNavigate }) {
|
||||
const { user, logout } = useAuth()
|
||||
const [program, setProgram] = useState(null)
|
||||
const [todayWorkout, setTodayWorkout] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState(getWeekStart(new Date()))
|
||||
const [recentSessions, setRecentSessions] = useState(PLACEHOLDER_SESSIONS)
|
||||
const [monthlyStats, setMonthlyStats] = useState(PLACEHOLDER_MONTHLY)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -62,25 +87,42 @@ function Dashboard({ onStartWorkout, onNavigate }) {
|
||||
const res = await fetch(`${API_URL}/programs/1`)
|
||||
const data = await res.json()
|
||||
setProgram(data)
|
||||
|
||||
// Determine today's workout based on day of week
|
||||
|
||||
const dayOfWeek = new Date().getDay()
|
||||
const adjustedDay = dayOfWeek === 0 ? 7 : dayOfWeek
|
||||
const todayDay = data.days?.find(d => d.day_number === adjustedDay)
|
||||
setTodayWorkout(todayDay || null)
|
||||
|
||||
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// Fetch workout history (graceful fallback)
|
||||
try {
|
||||
const histRes = await fetch(`${API_URL}/user/workout-history?user_id=1&limit=5`)
|
||||
if (histRes.ok) {
|
||||
const histData = await histRes.json()
|
||||
if (Array.isArray(histData) && histData.length > 0) {
|
||||
setRecentSessions(histData.slice(0, 4))
|
||||
// Calculate monthly stats from history
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthSessions = histData.filter(s => new Date(s.date) >= monthStart)
|
||||
const totalVol = monthSessions.reduce((sum, s) => sum + (s.volume || 0), 0)
|
||||
setMonthlyStats(prev => ({ ...prev, total_volume: totalVol || prev.total_volume }))
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// use placeholder data
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard loading">
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0e0e0e' }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Laddar...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,131 +130,501 @@ function Dashboard({ onStartWorkout, onNavigate }) {
|
||||
const workoutDays = program?.days?.map(d => d.day_number) || []
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div className="header-top">
|
||||
<h1 className="brand-title">
|
||||
<Logo />
|
||||
<span className="brand-name">Gravl</span>
|
||||
</h1>
|
||||
<nav className="nav-menu">
|
||||
<button className="nav-btn active"><Icon name="home" size={18} /></button>
|
||||
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
|
||||
<button className="nav-btn" onClick={() => onNavigate('encyclopedia')} title="Exercise Encyclopedia"><Icon name="search" size={18} /></button>
|
||||
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
|
||||
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
|
||||
</nav>
|
||||
</div>
|
||||
<div style={{ minHeight: '100vh', background: '#0e0e0e', paddingBottom: '80px' }}>
|
||||
{/* TOP HEADER */}
|
||||
<header style={{
|
||||
background: '#0e0e0e',
|
||||
padding: '1rem 1.25rem 0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 800,
|
||||
fontSize: '1.25rem',
|
||||
letterSpacing: '0.12em',
|
||||
color: '#cafd00',
|
||||
textTransform: 'uppercase',
|
||||
}}>KINETIC</span>
|
||||
<button
|
||||
onClick={() => onNavigate('profile')}
|
||||
style={{
|
||||
width: 38, height: 38,
|
||||
borderRadius: '50%',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #262626',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: '#adaaaa',
|
||||
}}
|
||||
>
|
||||
<Icon name="user" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="dashboard-main">
|
||||
{/* Week Calendar - TOP */}
|
||||
<section className="week-calendar">
|
||||
<div className="calendar-header">
|
||||
<main style={{ padding: '0 1.25rem' }}>
|
||||
{/* MONTHLY HERO */}
|
||||
<section style={{ marginTop: '1.25rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{
|
||||
background: '#131313',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem 1.25rem 1.25rem',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Subtle lime glow top-right */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
width: 120, height: 120,
|
||||
background: 'radial-gradient(circle at top right, rgba(202,253,0,0.08), transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 800,
|
||||
fontSize: '1.4rem',
|
||||
lineHeight: 1.15,
|
||||
color: '#ffffff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02em',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<span style={{ color: '#cafd00' }}>{monthlyStats.stronger_pct}%</span>{' '}
|
||||
STARKARE ÄN{' '}
|
||||
<span style={{ color: '#adaaaa', fontWeight: 600 }}>FÖRRA MÅNADEN</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
{/* Streak badge */}
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: 'rgba(202,253,0,0.1)',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(202,253,0,0.2)',
|
||||
}}>
|
||||
<Icon name="fire" size={14} />
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: '#cafd00',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>{monthlyStats.streak} DAGARS STREAK</span>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'block',
|
||||
}}>Denna månad</span>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
color: '#ffffff',
|
||||
}}>{formatVolume(monthlyStats.total_volume)} <span style={{ color: '#767575', fontSize: '0.75rem', fontFamily: 'Space Grotesk' }}>KG</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* WEEK CALENDAR */}
|
||||
<section style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '10px',
|
||||
padding: '0.875rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
className="calendar-nav"
|
||||
onClick={() => setCurrentWeekStart(addDays(currentWeekStart, -7))}
|
||||
style={{ background: 'none', border: 'none', color: '#adaaaa', cursor: 'pointer', padding: '0.25rem' }}
|
||||
>
|
||||
<Icon name="chevronLeft" size={16} />
|
||||
</button>
|
||||
<span className="calendar-title">
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#adaaaa',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatWeekRange(currentWeekStart)}
|
||||
</span>
|
||||
<button
|
||||
className="calendar-nav"
|
||||
onClick={() => setCurrentWeekStart(addDays(currentWeekStart, 7))}
|
||||
style={{ background: 'none', border: 'none', color: '#adaaaa', cursor: 'pointer', padding: '0.25rem' }}
|
||||
>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-days">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '0.25rem' }}>
|
||||
{weekdays.map((name, idx) => {
|
||||
const date = addDays(currentWeekStart, idx)
|
||||
const dayNum = idx + 1
|
||||
const isToday = isSameDay(date, new Date())
|
||||
const hasWorkout = workoutDays.includes(dayNum)
|
||||
const workout = program?.days?.find(d => d.day_number === dayNum)
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`calendar-day ${isToday ? 'today' : ''} ${hasWorkout ? 'has-workout' : ''}`}
|
||||
onClick={() => hasWorkout && workout && onStartWorkout(workout)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.5rem 0.25rem',
|
||||
borderRadius: '8px',
|
||||
background: isToday ? 'rgba(202,253,0,0.1)' : 'transparent',
|
||||
border: isToday ? '1px solid rgba(202,253,0,0.25)' : '1px solid transparent',
|
||||
cursor: hasWorkout ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<span className="day-name">{name}</span>
|
||||
<span className="day-date">{date.getDate()}</span>
|
||||
{hasWorkout && <span className="day-dot" />}
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: isToday ? '#cafd00' : '#767575',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}>{name}</span>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: isToday ? 700 : 500,
|
||||
fontSize: '0.9rem',
|
||||
color: isToday ? '#cafd00' : '#ffffff',
|
||||
}}>{date.getDate()}</span>
|
||||
{hasWorkout && (
|
||||
<span style={{
|
||||
width: 4, height: 4, borderRadius: '50%',
|
||||
background: isToday ? '#cafd00' : '#adaaaa',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Coach Section with Today's Action */}
|
||||
<section className="coach-section">
|
||||
<div className="coach-greeting">
|
||||
<div className="coach-avatar">
|
||||
<Icon name="coach" size={36} />
|
||||
{/* COACH GREETING */}
|
||||
<section style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '0.875rem',
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40,
|
||||
borderRadius: '50%',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #262626',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
color: '#cafd00',
|
||||
}}>
|
||||
<Icon name="coach" size={22} />
|
||||
</div>
|
||||
<div className="coach-message">
|
||||
<p>{getCoachGreeting(user, todayWorkout)}</p>
|
||||
<div style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '10px',
|
||||
padding: '0.75rem 1rem',
|
||||
flex: 1,
|
||||
}}>
|
||||
<p style={{
|
||||
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
color: '#adaaaa',
|
||||
lineHeight: 1.5,
|
||||
}}>{getCoachGreeting(user, todayWorkout)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Action */}
|
||||
<div className="today-action">
|
||||
{todayWorkout ? (
|
||||
// Workout today - show workout card
|
||||
<div className="today-workout-card" onClick={() => onStartWorkout(todayWorkout)}>
|
||||
<div className="workout-info">
|
||||
<h3>{todayWorkout.name}</h3>
|
||||
<span className="workout-meta">
|
||||
{todayWorkout.exercises?.filter(e => e.name).length} övningar • ~45 min
|
||||
</span>
|
||||
</div>
|
||||
<div className="workout-action">
|
||||
<Icon name="arrowRight" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Rest day - show tips + add button
|
||||
<div className="rest-day-section">
|
||||
<div className="rest-tips">
|
||||
{restDayTips.map((tip, i) => (
|
||||
<span key={i} className="tip-badge">
|
||||
<Icon name={tip.iconName} size={16} />
|
||||
{tip.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="add-workout-btn"
|
||||
onClick={() => onNavigate('select-workout')}
|
||||
>
|
||||
<Icon name="plus" size={20} />
|
||||
<span>Lägg till pass</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<section className="quick-stats">
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{workoutDays.length}</span>
|
||||
<span className="stat-label">Pass/vecka</span>
|
||||
{/* TODAY'S WORKOUT CARD */}
|
||||
<section style={{ marginBottom: '1.75rem' }}>
|
||||
{todayWorkout ? (
|
||||
<div
|
||||
onClick={() => onStartWorkout(todayWorkout)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1a1a1a 0%, #131313 100%)',
|
||||
border: '1px solid rgba(202,253,0,0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.25rem',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0,
|
||||
height: 3,
|
||||
background: 'linear-gradient(90deg, #cafd00, transparent)',
|
||||
}} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#cafd00',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'block',
|
||||
marginBottom: '0.25rem',
|
||||
}}>Dagens pass</span>
|
||||
<h3 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.25rem',
|
||||
color: '#ffffff',
|
||||
}}>{todayWorkout.name}</h3>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 36, height: 36,
|
||||
borderRadius: '8px',
|
||||
background: '#cafd00',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#516700',
|
||||
}}>
|
||||
<Icon name="arrowRight" size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#adaaaa',
|
||||
letterSpacing: '0.03em',
|
||||
}}>
|
||||
{todayWorkout.exercises?.filter(e => e.name).length || 0} övningar
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#adaaaa',
|
||||
letterSpacing: '0.03em',
|
||||
}}>~45 min</span>
|
||||
</div>
|
||||
|
||||
<button className="btn-kinetic" style={{ width: '100%', fontSize: '0.875rem', padding: '0.875rem' }}>
|
||||
STARTA PASS
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '12px',
|
||||
padding: '1.25rem',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
color: '#ffffff',
|
||||
marginBottom: '0.875rem',
|
||||
}}>Vilodag</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
{restDayTips.map((tip, i) => (
|
||||
<span key={i} style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: '#131313',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #262626',
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#adaaaa',
|
||||
}}>
|
||||
<Icon name={tip.iconName} size={14} />
|
||||
{tip.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate('select-workout')}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
background: '#131313',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '8px',
|
||||
color: '#adaaaa',
|
||||
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
Lägg till pass
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* RECENT SESSIONS */}
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.875rem' }}>
|
||||
<h2 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
color: '#ffffff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
}}>Senaste pass</h2>
|
||||
<button
|
||||
onClick={() => onNavigate('progress')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#cafd00',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.03em',
|
||||
}}
|
||||
>Se alla →</button>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">2</span>
|
||||
<span className="stat-label">Denna vecka</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value stat-icon"><Icon name="fire" size={28} /></span>
|
||||
<span className="stat-label">Streak: 5</span>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{recentSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={session.is_pr ? 'intensity-bar-orange' : 'intensity-bar-lime'}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '10px',
|
||||
padding: '0.875rem 0.875rem 0.875rem 1.25rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9375rem',
|
||||
color: '#ffffff',
|
||||
}}>{session.name}</span>
|
||||
{session.is_pr && (
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
color: '#516700',
|
||||
background: '#cafd00',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
letterSpacing: '0.04em',
|
||||
}}>PR</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.72rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.03em',
|
||||
}}>
|
||||
{formatSessionDate(session.date)} · {session.duration} min · {session.exercise_count} övningar
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
color: '#cafd00',
|
||||
}}>{formatVolume(session.volume)}</span>
|
||||
<span style={{
|
||||
display: 'block',
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#767575',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>kg</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* BOTTOM GLASSMORPHISM NAV */}
|
||||
<nav
|
||||
className="glass-nav"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '0.625rem 0 0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ icon: 'home', label: 'Idag', nav: null, active: true },
|
||||
{ icon: 'chart', label: 'Framsteg', nav: 'progress', active: false },
|
||||
{ icon: 'target', label: 'Mål', nav: 'benchmarks', active: false },
|
||||
{ icon: 'search', label: 'Övningar', nav: 'encyclopedia', active: false },
|
||||
{ icon: 'user', label: 'Profil', nav: 'profile', active: false },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => item.nav ? onNavigate(item.nav) : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem 0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: item.active ? '#cafd00' : '#767575' }}>
|
||||
<Icon name={item.icon} size={20} />
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: item.active ? '#cafd00' : '#767575',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -232,16 +644,16 @@ function addDays(date, days) {
|
||||
}
|
||||
|
||||
function isSameDay(d1, d2) {
|
||||
return d1.getDate() === d2.getDate() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getFullYear() === d2.getFullYear()
|
||||
return d1.getDate() === d2.getDate() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getFullYear() === d2.getFullYear()
|
||||
}
|
||||
|
||||
function formatWeekRange(weekStart) {
|
||||
const end = addDays(weekStart, 6)
|
||||
const startMonth = weekStart.toLocaleDateString('sv-SE', { month: 'short' })
|
||||
const endMonth = end.toLocaleDateString('sv-SE', { month: 'short' })
|
||||
|
||||
|
||||
if (startMonth === endMonth) {
|
||||
return `${weekStart.getDate()} - ${end.getDate()} ${startMonth}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0e0e0e;
|
||||
padding: 1.5rem 1.1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Lime radial glow behind logo */
|
||||
.login-glow {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 500px;
|
||||
height: 380px;
|
||||
background: radial-gradient(ellipse at center, rgba(202, 253, 0, 0.07) 0%, transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 390px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- Logo block ---- */
|
||||
.login-logo-block {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.login-wordmark {
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 3rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: #cafd00;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 40px rgba(202, 253, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #767575;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Error ---- */
|
||||
.login-error {
|
||||
background: rgba(255, 115, 81, 0.1);
|
||||
color: #ff7351;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 3px solid #ff7351;
|
||||
}
|
||||
|
||||
/* ---- Form ---- */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.login-field-label {
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #767575;
|
||||
}
|
||||
|
||||
.login-input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: none;
|
||||
border-bottom: 2px solid #262626;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: #ffffff;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
transition: border-color 150ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-bottom-color: #cafd00;
|
||||
background: #20201f;
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: #484847;
|
||||
}
|
||||
|
||||
.login-input-wrap .login-input {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.login-toggle-pw {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #767575;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.login-toggle-pw:hover {
|
||||
color: #adaaaa;
|
||||
}
|
||||
|
||||
/* ---- Primary CTA ---- */
|
||||
.login-btn-primary {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #cafd00 0%, #beee00 100%);
|
||||
color: #516700;
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
box-shadow: 0 4px 20px rgba(202, 253, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.login-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 28px rgba(202, 253, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(81, 103, 0, 0.3);
|
||||
border-top-color: #516700;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- Forgot / register link ---- */
|
||||
.login-forgot {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: #ff7440;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 0;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.login-forgot:hover {
|
||||
color: #ff8c5a;
|
||||
}
|
||||
|
||||
/* ---- Divider ---- */
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.login-divider span {
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #484847;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---- Ghost button ---- */
|
||||
.login-btn-ghost {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
background: transparent;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 6px;
|
||||
color: #adaaaa;
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-btn-ghost:hover {
|
||||
border-color: #484847;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.login-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 2.5rem;
|
||||
color: #484847;
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Logo from '../components/Logo';
|
||||
import './LoginPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
@@ -25,18 +26,93 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<Logo />
|
||||
<h1 className="auth-title">Logga in</h1>
|
||||
<p className="auth-tagline">Din personliga träningspartner</p>
|
||||
{error && <div className="error auth-error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
<button type="submit" disabled={loading}>{loading ? 'Loggar in...' : 'Logga in'}</button>
|
||||
<div className="login-page">
|
||||
<div className="login-glow" />
|
||||
|
||||
<div className="login-container">
|
||||
{/* Logo */}
|
||||
<div className="login-logo-block">
|
||||
<div className="login-wordmark">GRAVL</div>
|
||||
<p className="login-tagline">Track. Progress. Dominate.</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="login-field">
|
||||
<label className="login-field-label">E-POST</label>
|
||||
<input
|
||||
type="email"
|
||||
className="login-input"
|
||||
placeholder="din@epost.se"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-field">
|
||||
<label className="login-field-label">LÖSENORD</label>
|
||||
<div className="login-input-wrap">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="login-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-toggle-pw"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-btn-primary" disabled={loading}>
|
||||
{loading ? (
|
||||
<span className="login-spinner" />
|
||||
) : (
|
||||
'LOGGA IN'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-link">Inget konto? <Link to="/register">Skapa konto</Link></p>
|
||||
|
||||
<Link to="/register" className="login-forgot">Inget konto? Skapa ett →</Link>
|
||||
|
||||
<div className="login-divider">
|
||||
<span>eller</span>
|
||||
</div>
|
||||
|
||||
<Link to="/register" className="login-btn-ghost">SKAPA KONTO</Link>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="login-footer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>Din data. Krypterad. Alltid.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+434
-168
@@ -1,186 +1,455 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import '../styles/kinetic-precision.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
// Placeholder workout history
|
||||
const PLACEHOLDER_HISTORY = [
|
||||
{ id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 1 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true, exercises: ['Bänkpress', 'Incline DB Press', 'Cable Flyes', 'Tricep Pushdowns', 'Overhead Ext', 'Dips'] },
|
||||
{ id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 3 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false, exercises: ['Lat Pulldown', 'Seated Row', 'Face Pulls', 'Barbell Curl', 'Hammer Curl', 'Reverse Curl', 'Shrugs'] },
|
||||
{ id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 5 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false, exercises: ['Knäböj', 'Leg Press', 'Leg Curl', 'Leg Ext', 'Military Press', 'Lateral Raise', 'Front Raise', 'Rear Delt Fly'] },
|
||||
{ id: 4, name: 'Push', date: new Date(Date.now() - 8 * 86400000).toISOString(), duration: 55, exercise_count: 6, volume: 9100, is_pr: false, exercises: ['Bänkpress', 'OHP', 'DB Press', 'Cable Flyes', 'Tricep Ext', 'Lateral Raise'] },
|
||||
{ id: 5, name: 'Pull', date: new Date(Date.now() - 10 * 86400000).toISOString(), duration: 46, exercise_count: 5, volume: 10500, is_pr: false, exercises: ['Marklyft', 'Pull-ups', 'Seated Row', 'Face Pulls', 'Bicep Curl'] },
|
||||
]
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('sv-SE', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function formatVolume(kg) {
|
||||
if (kg >= 1000) {
|
||||
return `${Math.round(kg / 100) / 10} 000`
|
||||
}
|
||||
return `${kg}`
|
||||
}
|
||||
|
||||
function ProgressPage({ onBack }) {
|
||||
const { user } = useAuth()
|
||||
const [measurements, setMeasurements] = useState([])
|
||||
const [strength, setStrength] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('weight')
|
||||
const [workoutHistory, setWorkoutHistory] = useState(PLACEHOLDER_HISTORY)
|
||||
const [expandedSession, setExpandedSession] = useState(null)
|
||||
|
||||
// Monthly summary computed from history
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthSessions = workoutHistory.filter(s => new Date(s.date) >= monthStart)
|
||||
const totalVolume = workoutHistory.reduce((sum, s) => sum + (s.volume || 0), 0)
|
||||
const streak = 14 // placeholder
|
||||
const sessionCount = workoutHistory.length
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
// Try workout history first
|
||||
try {
|
||||
const histRes = await fetch(`${API_URL}/user/workout-history?user_id=${user?.id || 1}`)
|
||||
if (histRes.ok) {
|
||||
const histData = await histRes.json()
|
||||
if (Array.isArray(histData) && histData.length > 0) {
|
||||
setWorkoutHistory(histData)
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// use placeholder
|
||||
}
|
||||
|
||||
// Try measurements and strength
|
||||
try {
|
||||
const [measurementsRes, strengthRes] = await Promise.all([
|
||||
fetch(`${API_URL}/user/measurements/${user?.id || 1}`),
|
||||
fetch(`${API_URL}/user/strength/${user?.id || 1}`)
|
||||
])
|
||||
|
||||
const measurementsData = await measurementsRes.json()
|
||||
const strengthData = await strengthRes.json()
|
||||
|
||||
// Sort by date ascending for charts
|
||||
setMeasurements([...measurementsData].reverse())
|
||||
setStrength([...strengthData].reverse())
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch progress:', err)
|
||||
setLoading(false)
|
||||
} catch (_) {
|
||||
// silent
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="progress-page loading">
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0e0e0e' }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Laddar progress...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="progress-page">
|
||||
<header className="page-header">
|
||||
<button className="back-btn" onClick={onBack}>← Tillbaka</button>
|
||||
<h1>Min progress</h1>
|
||||
<div style={{ width: 40 }}></div>
|
||||
<div style={{ minHeight: '100vh', background: '#0e0e0e', paddingBottom: '2rem' }}>
|
||||
{/* HEADER */}
|
||||
<header style={{
|
||||
background: '#0e0e0e',
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#adaaaa',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
← Tillbaka
|
||||
</button>
|
||||
<h1 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 800,
|
||||
fontSize: '1rem',
|
||||
color: '#ffffff',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
}}>Framsteg & Historik</h1>
|
||||
<div style={{ width: 70 }} />
|
||||
</header>
|
||||
|
||||
<main className="page-main">
|
||||
{/* Tab Navigation */}
|
||||
<div className="progress-tabs">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'weight' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('weight')}
|
||||
>
|
||||
⚖️ Vikt
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'bodyfat' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('bodyfat')}
|
||||
>
|
||||
📊 Kroppsfett
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'strength' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('strength')}
|
||||
>
|
||||
💪 Styrka
|
||||
</button>
|
||||
</div>
|
||||
<main style={{ padding: '1.25rem' }}>
|
||||
{/* MONTHLY SUMMARY BAR */}
|
||||
<section style={{
|
||||
background: '#131313',
|
||||
borderRadius: '10px',
|
||||
padding: '1rem',
|
||||
marginBottom: '1.5rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '0',
|
||||
}}>
|
||||
{[
|
||||
{ label: 'Volym', value: formatVolume(totalVolume), unit: 'KG' },
|
||||
{ label: 'Streak', value: String(streak), unit: 'DAGAR' },
|
||||
{ label: 'Pass', value: String(sessionCount), unit: 'TOTALT' },
|
||||
].map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem 0',
|
||||
borderRight: i < 2 ? '1px solid #1f1f1f' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 800,
|
||||
fontSize: '1.25rem',
|
||||
color: '#cafd00',
|
||||
lineHeight: 1.1,
|
||||
}}>{stat.value}</span>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: '0.125rem',
|
||||
}}>{stat.unit}</span>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#adaaaa',
|
||||
marginTop: '0.125rem',
|
||||
}}>{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Weight Chart */}
|
||||
{activeTab === 'weight' && (
|
||||
<section className="chart-section">
|
||||
<h2>Viktutveckling</h2>
|
||||
{measurements.length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
color="var(--accent)"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
label="Vikt"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga viktmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{/* WORKOUT HISTORY */}
|
||||
<section style={{ marginBottom: '1.75rem' }}>
|
||||
<h2 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8125rem',
|
||||
color: '#ffffff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '0.875rem',
|
||||
}}>Träningshistorik</h2>
|
||||
|
||||
{/* Body Fat Chart */}
|
||||
{activeTab === 'bodyfat' && (
|
||||
<section className="chart-section">
|
||||
<h2>Kroppsfett</h2>
|
||||
{measurements.filter(m => m.body_fat_pct).length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
color="#10b981"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
label="Kroppsfett"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga kroppsfettmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
|
||||
{workoutHistory.map((session) => (
|
||||
<div key={session.id}>
|
||||
<div
|
||||
className={session.is_pr ? 'intensity-bar-orange' : 'intensity-bar-lime'}
|
||||
onClick={() => setExpandedSession(expandedSession === session.id ? null : session.id)}
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
borderRadius: expandedSession === session.id ? '10px 10px 0 0' : '10px',
|
||||
padding: '0.875rem 0.875rem 0.875rem 1.25rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.03em',
|
||||
display: 'block',
|
||||
marginBottom: '0.25rem',
|
||||
textTransform: 'capitalize',
|
||||
}}>{formatDate(session.date)}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
color: '#ffffff',
|
||||
}}>{session.name}</span>
|
||||
{session.is_pr && (
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: '#516700',
|
||||
background: '#cafd00',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
letterSpacing: '0.04em',
|
||||
}}>PR</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.02em',
|
||||
}}>{session.duration} min · {session.exercise_count} övningar</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
color: '#cafd00',
|
||||
}}>{formatVolume(session.volume)}</span>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#767575',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strength Charts */}
|
||||
{activeTab === 'strength' && (
|
||||
<section className="chart-section">
|
||||
<h2>Styrkerekord (1RM)</h2>
|
||||
{strength.length > 0 ? (
|
||||
<div className="strength-charts">
|
||||
<div className="strength-chart-item">
|
||||
<h3>🏋️ Bänkpress</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
label="Bänkpress"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3>🦵 Knäböj</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
label="Knäböj"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3>💀 Marklyft</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
color="#ef4444"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
label="Marklyft"
|
||||
/>
|
||||
</div>
|
||||
{/* Expanded exercise list */}
|
||||
{expandedSession === session.id && session.exercises && (
|
||||
<div style={{
|
||||
background: '#131313',
|
||||
borderRadius: '0 0 10px 10px',
|
||||
padding: '0.75rem 1.25rem 0.875rem',
|
||||
borderTop: '1px solid #1f1f1f',
|
||||
}}>
|
||||
{session.exercises.map((ex, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.3rem 0',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 4, height: 4, borderRadius: '50%',
|
||||
background: '#767575',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||
fontSize: '0.8125rem',
|
||||
color: '#adaaaa',
|
||||
}}>{ex}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="Inga styrkerekord registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ANALYTICS SECTION (existing tabs - secondary) */}
|
||||
<section>
|
||||
<h2 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8125rem',
|
||||
color: '#ffffff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '0.875rem',
|
||||
}}>Mätningar & Styrka</h2>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
background: '#131313',
|
||||
borderRadius: '8px',
|
||||
padding: '0.25rem',
|
||||
marginBottom: '1rem',
|
||||
gap: '0.25rem',
|
||||
}}>
|
||||
{[
|
||||
{ key: 'weight', label: 'Vikt' },
|
||||
{ key: 'bodyfat', label: 'Kroppsfett' },
|
||||
{ key: 'strength', label: 'Styrka' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.5rem',
|
||||
background: activeTab === tab.key ? '#1a1a1a' : 'transparent',
|
||||
border: activeTab === tab.key ? '1px solid #262626' : '1px solid transparent',
|
||||
borderRadius: '6px',
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: activeTab === tab.key ? '#ffffff' : '#767575',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.03em',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
>{tab.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weight Chart */}
|
||||
{activeTab === 'weight' && (
|
||||
<section className="chart-section">
|
||||
{measurements.length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
color="var(--accent)"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
label="Vikt"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga viktmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Body Fat Chart */}
|
||||
{activeTab === 'bodyfat' && (
|
||||
<section className="chart-section">
|
||||
{measurements.filter(m => m.body_fat_pct).length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
color="#10b981"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
label="Kroppsfett"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga kroppsfettmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Strength Charts */}
|
||||
{activeTab === 'strength' && (
|
||||
<section className="chart-section">
|
||||
{strength.length > 0 ? (
|
||||
<div className="strength-charts">
|
||||
<div className="strength-chart-item">
|
||||
<h3 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>Bänkpress</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
label="Bänkpress"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>Knäböj</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
label="Knäböj"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>Marklyft</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
color="#ef4444"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
label="Marklyft"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="Inga styrkerekord registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -189,21 +458,20 @@ function ProgressPage({ onBack }) {
|
||||
// Simple SVG Line Chart Component
|
||||
function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
|
||||
const values = data.map(d => d[valueKey]).filter(v => v != null)
|
||||
if (values.length === 0) return null
|
||||
|
||||
const min = Math.min(...values) * 0.95
|
||||
const max = Math.max(...values) * 1.05
|
||||
const range = max - min || 1
|
||||
|
||||
|
||||
const width = 320
|
||||
const height = 160
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
|
||||
// Generate points
|
||||
const points = data.map((d, i) => {
|
||||
const x = padding.left + (i / Math.max(data.length - 1, 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((d[valueKey] - min) / range) * chartHeight
|
||||
@@ -211,14 +479,11 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
}).filter(p => p.value != null)
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = [min, (min + max) / 2, max].map(v => v.toFixed(1))
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="line-chart">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.5, 1].map((ratio, i) => (
|
||||
<line
|
||||
key={i}
|
||||
@@ -230,8 +495,6 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
strokeDasharray="4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yLabels.map((label, i) => (
|
||||
<text
|
||||
key={i}
|
||||
@@ -244,8 +507,6 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
@@ -254,30 +515,21 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Points */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
fill={color}
|
||||
/>
|
||||
<circle key={i} cx={p.x} cy={p.y} r="4" fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
<div className="chart-labels">
|
||||
<span>{formatDate(data[0]?.created_at)}</span>
|
||||
<span>{formatDate(data[data.length - 1]?.created_at)}</span>
|
||||
<span>{formatDateShort(data[0]?.created_at)}</span>
|
||||
<span>{formatDateShort(data[data.length - 1]?.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Statistics Component
|
||||
function ProgressStats({ data, valueKey, unit, label }) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
|
||||
const values = data.map(d => d[valueKey]).filter(v => v != null)
|
||||
if (values.length === 0) return null
|
||||
|
||||
@@ -310,15 +562,29 @@ function ProgressStats({ data, valueKey, unit, label }) {
|
||||
|
||||
function EmptyState({ message }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-icon">📊</span>
|
||||
<p>{message}</p>
|
||||
<p className="empty-hint">Logga mätningar för att se din progress</p>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '2rem 1rem',
|
||||
background: '#131313',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<p style={{
|
||||
fontFamily: 'Plus Jakarta Sans, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
color: '#767575',
|
||||
marginBottom: '0.5rem',
|
||||
}}>{message}</p>
|
||||
<p style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#484847',
|
||||
letterSpacing: '0.03em',
|
||||
}}>Logga mätningar för att se din progress</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDateShort(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Icon } from '../components/Icons'
|
||||
import SwapWorkoutModal from '../components/SwapWorkoutModal'
|
||||
import '../styles/kinetic-precision.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
@@ -453,6 +454,8 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
onSwap={() => openAlternatives(exercise)}
|
||||
onUndo={() => undoSwap(exercise.id)}
|
||||
canUndo={Boolean(recentSwaps[exercise.id])}
|
||||
exerciseIndex={idx + 1}
|
||||
totalExercises={exercises.length}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -488,7 +491,7 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo }) {
|
||||
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo, exerciseIndex, totalExercises }) {
|
||||
const [setList, setSetList] = useState([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const weightStep = 2.5
|
||||
@@ -569,19 +572,62 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
|
||||
const completedSets = setList.filter(s => s.completed).length
|
||||
|
||||
// Compute PR: current set weight exceeds progression last weight
|
||||
const isPR = (input, idx) => {
|
||||
const lastWeight = progression?.lastWeight
|
||||
if (!lastWeight) return false
|
||||
const w = parseFloat(input.weight)
|
||||
return !isNaN(w) && w > lastWeight
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`}>
|
||||
<div className="exercise-header" onClick={onToggle}>
|
||||
<div className="exercise-info">
|
||||
<h3>{exercise.name}</h3>
|
||||
<span className="muscle-group">{exercise.muscle_group}</span>
|
||||
{isSwapped && originalExercise && (
|
||||
<span className="swap-badge">Bytt från {originalExercise.name}</span>
|
||||
{/* EXERCISE FOCUS HEADER */}
|
||||
<div className="exercise-header" onClick={onToggle} style={{ paddingBottom: expanded ? '0.5rem' : undefined }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* Progress indicator */}
|
||||
{exerciseIndex != null && (
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.65rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'block',
|
||||
marginBottom: '0.25rem',
|
||||
}}>Övning {exerciseIndex} av {totalExercises}</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h3 style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.1rem',
|
||||
color: '#ffffff',
|
||||
margin: 0,
|
||||
}}>{exercise.name}</h3>
|
||||
{isSwapped && originalExercise && (
|
||||
<span className="swap-badge" style={{ fontSize: '0.6rem' }}>Bytt</span>
|
||||
)}
|
||||
</div>
|
||||
{exercise.muscle_group && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '0.25rem',
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.03em',
|
||||
}}>{exercise.muscle_group}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="exercise-actions">
|
||||
<div className="exercise-meta">
|
||||
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#adaaaa',
|
||||
letterSpacing: '0.03em',
|
||||
}}>{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||||
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
|
||||
{completedSets}/{setList.length}
|
||||
</span>
|
||||
@@ -617,8 +663,9 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
|
||||
{expanded && (
|
||||
<div className="exercise-body">
|
||||
{/* Progression hint */}
|
||||
{progression && (
|
||||
<div className="progression-hint">
|
||||
<div className="progression-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
{progression.reason}
|
||||
{progression.suggestedWeight && (
|
||||
<strong> {progression.suggestedWeight} kg</strong>
|
||||
@@ -626,80 +673,154 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target line */}
|
||||
{(exercise.reps_min || exercise.reps_max) && (
|
||||
<div style={{
|
||||
background: '#131313',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>Mål</span>
|
||||
<span style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
color: '#adaaaa',
|
||||
}}>
|
||||
{exercise.sets} set · {exercise.reps_min}{exercise.reps_max && exercise.reps_max !== exercise.reps_min ? `–${exercise.reps_max}` : ''} reps
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sets-list">
|
||||
{setList.map((input, idx) => (
|
||||
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||||
<div className="set-row-top">
|
||||
<span className="set-number">Set {idx + 1}</span>
|
||||
{setList.map((input, idx) => {
|
||||
const setIsPR = isPR(input, idx)
|
||||
return (
|
||||
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||||
<div className="set-row-top">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span className="set-number" style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}>Set {idx + 1}</span>
|
||||
{setIsPR && (
|
||||
<span style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: 700,
|
||||
color: '#516700',
|
||||
background: '#cafd00',
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '4px',
|
||||
letterSpacing: '0.04em',
|
||||
}}>PR</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => handleDeleteSet(idx)}
|
||||
disabled={setList.length <= 1}
|
||||
aria-label={`Ta bort set ${idx + 1}`}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="set-controls">
|
||||
<div className="set-metric">
|
||||
<span className="metric-label">Vikt</span>
|
||||
<div className="metric-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
|
||||
aria-label="Minska vikt"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="metric-value">
|
||||
<span className="metric-number" style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
color: '#cafd00',
|
||||
fontSize: '1.35rem',
|
||||
fontWeight: 700,
|
||||
}}>{input.weight === '' ? '0' : input.weight}</span>
|
||||
<span className="metric-suffix">kg</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'weight', weightStep)}
|
||||
aria-label="Öka vikt"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-metric">
|
||||
<span className="metric-label">Reps</span>
|
||||
<div className="metric-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
|
||||
aria-label="Minska reps"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="metric-value">
|
||||
<span className="metric-number" style={{
|
||||
fontFamily: 'Lexend, sans-serif',
|
||||
fontSize: '1.35rem',
|
||||
fontWeight: 700,
|
||||
}}>{input.reps === '' ? '0' : input.reps}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'reps', repsStep)}
|
||||
aria-label="Öka reps"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Previous session reference */}
|
||||
{progression?.lastWeight && progression?.lastReps && (
|
||||
<div style={{
|
||||
fontFamily: 'Space Grotesk, monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#767575',
|
||||
letterSpacing: '0.03em',
|
||||
marginTop: '0.25rem',
|
||||
marginBottom: '0.25rem',
|
||||
}}>
|
||||
Förra träningen: {progression.lastWeight}kg×{progression.lastReps}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => handleDeleteSet(idx)}
|
||||
disabled={setList.length <= 1}
|
||||
aria-label={`Ta bort set ${idx + 1}`}
|
||||
className={`klart-btn ${input.completed ? 'done' : ''}`}
|
||||
onClick={() => handleComplete(idx)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
{input.completed ? <Icon name="check" size={18} /> : null}
|
||||
KLART
|
||||
</button>
|
||||
</div>
|
||||
<div className="set-controls">
|
||||
<div className="set-metric">
|
||||
<span className="metric-label">Vikt</span>
|
||||
<div className="metric-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
|
||||
aria-label="Minska vikt"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="metric-value">
|
||||
<span className="metric-number">{input.weight === '' ? '0' : input.weight}</span>
|
||||
<span className="metric-suffix">kg</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'weight', weightStep)}
|
||||
aria-label="Öka vikt"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-metric">
|
||||
<span className="metric-label">Reps</span>
|
||||
<div className="metric-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
|
||||
aria-label="Minska reps"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="metric-value">
|
||||
<span className="metric-number">{input.reps === '' ? '0' : input.reps}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="metric-btn"
|
||||
onClick={() => handleAdjust(idx, 'reps', repsStep)}
|
||||
aria-label="Öka reps"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`klart-btn ${input.completed ? 'done' : ''}`}
|
||||
onClick={() => handleComplete(idx)}
|
||||
>
|
||||
{input.completed ? <Icon name="check" size={18} /> : null}
|
||||
KLART
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/* ============================================
|
||||
Kinetic Precision - Stitch Design System
|
||||
Shared component styles
|
||||
============================================ */
|
||||
|
||||
/* Glassmorphism nav */
|
||||
.glass-nav {
|
||||
background: rgba(26, 26, 26, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Kinetic button - lime gradient */
|
||||
.btn-kinetic {
|
||||
background: linear-gradient(135deg, #cafd00 0%, #beee00 100%);
|
||||
color: #516700;
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
box-shadow: 0 4px 16px rgba(202, 253, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-kinetic:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 24px rgba(202, 253, 0, 0.4);
|
||||
}
|
||||
|
||||
.btn-kinetic:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(202, 253, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Intensity indicator bar */
|
||||
.intensity-bar-lime {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intensity-bar-lime::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #cafd00;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.intensity-bar-orange {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.intensity-bar-orange::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #ff7440;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
/* Glow progress ring */
|
||||
.progress-ring-glow {
|
||||
filter: drop-shadow(0 0 8px rgba(202, 253, 0, 0.5));
|
||||
}
|
||||
|
||||
/* Data label - Space Grotesk */
|
||||
.data-label {
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: #adaaaa;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #cafd00;
|
||||
}
|
||||
|
||||
/* Section separator via background shift (no borders) */
|
||||
.surface-low { background: #131313; }
|
||||
.surface-mid { background: #1a1a1a; }
|
||||
.surface-high { background: #20201f; }
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #262626;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #cafd00 0%, #beee00 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 400ms ease;
|
||||
box-shadow: 0 0 8px rgba(202, 253, 0, 0.4);
|
||||
}
|
||||
|
||||
.progress-bar-fill.secondary {
|
||||
background: linear-gradient(90deg, #ff7440 0%, #ff8c5a 100%);
|
||||
box-shadow: 0 0 8px rgba(255, 116, 64, 0.4);
|
||||
}
|
||||
|
||||
/* Benchmark card */
|
||||
.benchmark-card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Stat chip - Space Grotesk number display */
|
||||
.stat-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-chip .stat-number {
|
||||
font-family: 'Lexend', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
color: #cafd00;
|
||||
}
|
||||
|
||||
.stat-chip .stat-unit {
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #767575;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Goal badge */
|
||||
.goal-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Space Grotesk', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.goal-badge.active {
|
||||
background: rgba(202, 253, 0, 0.12);
|
||||
color: #cafd00;
|
||||
}
|
||||
|
||||
.goal-badge.secondary {
|
||||
background: rgba(255, 116, 64, 0.12);
|
||||
color: #ff7440;
|
||||
}
|
||||
Reference in New Issue
Block a user