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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user