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:
2026-04-27 08:49:07 +02:00
parent b6c39574c2
commit 1f2a892391
20 changed files with 2380 additions and 516 deletions
+509 -97
View File
@@ -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}`
}