feat(fitness/active): rail + focus card layout
Redesign the active-workout page around a left-rail timeline and a focus card on the right. The rail owns the workout title, pause, elapsed time, sync indicator, progress bar, and a reorderable chip per exercise (drag to reorder, × to delete, starting-weight hint so you know what to rack, green checkmark when complete). Main stage holds a hero focus card for the active exercise plus its SetTable. - New WorkoutRail.svelte and WorkoutFocusCard.svelte - Active exercise pinned to top of the scrollable rail (mobile only) - Desktop: rail grows freely; mobile: compact vertical stack - Finish + cancel share one row; cancel is a ghost action - Drop the old sticky bottombar; its controls moved into the rail - ExerciseName gains `plain` prop to opt out of the detail link - Active workout route joins the 1400px max-width whitelist
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.47.4",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||
|
||||
let { exerciseId } = $props();
|
||||
let { exerciseId, plain = false } = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
|
||||
@@ -11,7 +11,11 @@
|
||||
</script>
|
||||
|
||||
{#if exercise}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{#if plain}
|
||||
<span class="exercise-plain">{exercise.localName}</span>
|
||||
{:else}
|
||||
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="exercise-unknown">Unknown Exercise</span>
|
||||
{/if}
|
||||
@@ -25,6 +29,10 @@
|
||||
.exercise-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.exercise-plain {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.exercise-unknown {
|
||||
color: var(--nord11);
|
||||
font-style: italic;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script>
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
|
||||
/** @type {{
|
||||
* exerciseId: string,
|
||||
* bodyPart?: string | null,
|
||||
* equipment?: string | null,
|
||||
* detailsHref?: string | null,
|
||||
* detailsLabel?: string,
|
||||
* exerciseIndex: number,
|
||||
* totalExercises: number,
|
||||
* sets: Array<{ completed?: boolean }>,
|
||||
* activeSetIdx: number,
|
||||
* labels: { exerciseOf: (i: number, n: number) => string, setOf: (i: number, n: number) => string, done: (n: number) => string },
|
||||
* }} */
|
||||
let {
|
||||
exerciseId,
|
||||
bodyPart = null,
|
||||
equipment = null,
|
||||
detailsHref = null,
|
||||
detailsLabel = 'Exercise details',
|
||||
exerciseIndex,
|
||||
totalExercises,
|
||||
sets,
|
||||
activeSetIdx,
|
||||
labels
|
||||
} = $props();
|
||||
|
||||
const totalSets = $derived(sets.length);
|
||||
const doneSets = $derived(sets.filter((s) => s.completed).length);
|
||||
const allDone = $derived(totalSets > 0 && doneSets === totalSets);
|
||||
</script>
|
||||
|
||||
<section class="focus-card" aria-label="Current exercise">
|
||||
<header class="focus-eyebrow">
|
||||
<span class="focus-step">{labels.exerciseOf(exerciseIndex + 1, totalExercises)}</span>
|
||||
{#if bodyPart}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{bodyPart}</span>
|
||||
{/if}
|
||||
{#if equipment}
|
||||
<span class="focus-dot-sep" aria-hidden="true">·</span>
|
||||
<span class="focus-meta">{equipment}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="focus-name-row">
|
||||
<h2 class="focus-name"><ExerciseName {exerciseId} plain /></h2>
|
||||
{#if detailsHref}
|
||||
<a class="focus-details" href={detailsHref} aria-label={detailsLabel} title={detailsLabel}>
|
||||
<ChevronRight size={18} strokeWidth={2.2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="focus-progress">
|
||||
<span class="focus-set-label" class:complete={allDone}>
|
||||
{allDone ? labels.done(totalSets) : labels.setOf(activeSetIdx + 1, totalSets)}
|
||||
</span>
|
||||
<span class="focus-dots" aria-hidden="true">
|
||||
{#each sets as s, si (si)}
|
||||
<span
|
||||
class="focus-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.focus-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem 1.25rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
||||
.focus-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.focus-step {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.focus-meta {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-dot-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Big display name */
|
||||
.focus-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.focus-details {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 100px;
|
||||
color: var(--color-text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: background 140ms, color 140ms, transform 140ms;
|
||||
}
|
||||
.focus-details:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Set progress line */
|
||||
.focus-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.focus-set-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.focus-set-label.complete {
|
||||
color: var(--nord14);
|
||||
}
|
||||
.focus-dots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.focus-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background 180ms, transform 180ms, border-color 180ms;
|
||||
}
|
||||
.focus-dot.filled {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.focus-dot.current {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 55%);
|
||||
border-color: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
animation: focus-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes focus-dot-pulse {
|
||||
0%, 100% { transform: scale(1.25); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.focus-card {
|
||||
padding: 0.9rem 1rem 0.85rem;
|
||||
}
|
||||
.focus-name {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,687 @@
|
||||
<script>
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
|
||||
/**
|
||||
* @typedef {{ exerciseId: string, sets: Array<{ completed?: boolean }> }} RailExercise
|
||||
*/
|
||||
|
||||
/** @type {{
|
||||
* exercises: RailExercise[],
|
||||
* activeIdx: number,
|
||||
* activeSetIdx: number,
|
||||
* elapsedLabel: string,
|
||||
* paused?: boolean,
|
||||
* syncStatus?: string,
|
||||
* setsDone: number,
|
||||
* setsTotal: number,
|
||||
* addLabel: string,
|
||||
* pauseLabel?: string,
|
||||
* resumeLabel?: string,
|
||||
* removeLabel?: string,
|
||||
* previousData?: Record<string, Array<{ weight?: number | null, reps?: number | null }>>,
|
||||
* weightUnit?: string,
|
||||
* onPauseToggle?: () => void,
|
||||
* onFocus: (idx: number) => void,
|
||||
* onAddExercise: () => void,
|
||||
* onRemove?: (idx: number) => void,
|
||||
* onReorder?: (fromIdx: number, toIdx: number) => void,
|
||||
* }} */
|
||||
let {
|
||||
exercises,
|
||||
activeIdx,
|
||||
activeSetIdx,
|
||||
elapsedLabel,
|
||||
paused = false,
|
||||
syncStatus = 'idle',
|
||||
setsDone,
|
||||
setsTotal,
|
||||
addLabel,
|
||||
pauseLabel = 'Pause',
|
||||
resumeLabel = 'Resume',
|
||||
removeLabel = 'Remove exercise',
|
||||
previousData = {},
|
||||
weightUnit = 'kg',
|
||||
title,
|
||||
onPauseToggle,
|
||||
onFocus,
|
||||
onAddExercise,
|
||||
onRemove,
|
||||
onReorder
|
||||
} = $props();
|
||||
|
||||
/** Drag-and-drop state */
|
||||
/** @type {number | null} */
|
||||
let draggedIdx = $state(null);
|
||||
/** @type {number | null} */
|
||||
let dragOverIdx = $state(null);
|
||||
|
||||
/** @type {HTMLOListElement | null} */
|
||||
let listEl = $state(null);
|
||||
|
||||
// Keep the active chip at the top of the scrollable list so the user sees current + the next two
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const idx = activeIdx;
|
||||
if (idx < 0) return;
|
||||
const items = listEl.querySelectorAll('.rail-item');
|
||||
const target = /** @type {HTMLElement | undefined} */ (items[idx]);
|
||||
if (!target) return;
|
||||
// Use scrollTop directly to keep the scroll local to the list (avoid page scroll)
|
||||
const listTop = listEl.getBoundingClientRect().top;
|
||||
const itemTop = target.getBoundingClientRect().top;
|
||||
listEl.scrollTo({ top: listEl.scrollTop + (itemTop - listTop), behavior: 'smooth' });
|
||||
});
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragStart(e, idx) {
|
||||
draggedIdx = idx;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires data to be set to initiate a drag
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDragOver(e, idx) {
|
||||
if (draggedIdx == null || draggedIdx === idx) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
dragOverIdx = idx;
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e @param {number} idx */
|
||||
function onDrop(e, idx) {
|
||||
e.preventDefault();
|
||||
if (draggedIdx != null && draggedIdx !== idx && onReorder) {
|
||||
onReorder(draggedIdx, idx);
|
||||
}
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedIdx = null;
|
||||
dragOverIdx = null;
|
||||
}
|
||||
|
||||
const progressPct = $derived(setsTotal > 0 ? (setsDone / setsTotal) * 100 : 0);
|
||||
|
||||
/**
|
||||
* What to rack: starting weight × reps for the first set.
|
||||
* Falls back to the previous session's first set if the current plan is blank.
|
||||
* @param {RailExercise} ex
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function startingLoadLabel(ex) {
|
||||
const first = ex.sets[0];
|
||||
const prev = previousData[ex.exerciseId]?.[0];
|
||||
/** @type {number | null | undefined} */
|
||||
const w = (first && typeof first === 'object' && 'weight' in first ? /** @type {any} */(first).weight : null) ?? prev?.weight;
|
||||
/** @type {number | null | undefined} */
|
||||
const r = (first && typeof first === 'object' && 'reps' in first ? /** @type {any} */(first).reps : null) ?? prev?.reps;
|
||||
if (w != null && w > 0 && r != null && r > 0) return `${w} ${weightUnit} × ${r}`;
|
||||
if (w != null && w > 0) return `${w} ${weightUnit}`;
|
||||
if (r != null && r > 0) return `× ${r}`;
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="workout-rail" aria-label="Workout overview">
|
||||
<header class="rail-header">
|
||||
{#if title}
|
||||
<div class="rail-title">{@render title()}</div>
|
||||
{/if}
|
||||
<div class="rail-timer-row">
|
||||
<button
|
||||
class="rail-pause"
|
||||
onclick={() => onPauseToggle?.()}
|
||||
aria-label={paused ? resumeLabel : pauseLabel}
|
||||
type="button"
|
||||
>
|
||||
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
|
||||
</button>
|
||||
<span class="rail-elapsed" class:paused>{elapsedLabel}</span>
|
||||
<span class="rail-sync"><SyncIndicator status={syncStatus} /></span>
|
||||
</div>
|
||||
<div class="rail-progress">
|
||||
<div class="rail-progress-bar">
|
||||
<div class="rail-progress-fill" style:width="{progressPct}%"></div>
|
||||
</div>
|
||||
<span class="rail-progress-label">{setsDone}<span class="rail-progress-sep">/</span>{setsTotal}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol class="rail-list" bind:this={listEl}>
|
||||
{#each exercises as ex, i (i)}
|
||||
{@const isActive = i === activeIdx}
|
||||
{@const done = ex.sets.filter((s) => s.completed).length}
|
||||
{@const complete = done === ex.sets.length && ex.sets.length > 0}
|
||||
{@const load = startingLoadLabel(ex)}
|
||||
{@const isDragging = draggedIdx === i}
|
||||
{@const isDragOver = dragOverIdx === i && draggedIdx !== i}
|
||||
{@const dropAbove = isDragOver && (draggedIdx ?? i) > i}
|
||||
{@const dropBelow = isDragOver && (draggedIdx ?? i) < i}
|
||||
<li
|
||||
class="rail-item"
|
||||
class:dragging={isDragging}
|
||||
class:drop-above={dropAbove}
|
||||
class:drop-below={dropBelow}
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
draggable={onReorder ? 'true' : undefined}
|
||||
ondragstart={(e) => onDragStart(e, i)}
|
||||
ondragover={(e) => onDragOver(e, i)}
|
||||
ondrop={(e) => onDrop(e, i)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<button
|
||||
class="rail-chip"
|
||||
class:active={isActive}
|
||||
class:complete
|
||||
onclick={() => onFocus(i)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<span class="rail-chip-index">{i + 1}</span>
|
||||
<span class="rail-chip-body">
|
||||
<span class="rail-chip-name"><ExerciseName exerciseId={ex.exerciseId} plain /></span>
|
||||
{#if load}
|
||||
<span class="rail-chip-load" aria-label="Starting load">{load}</span>
|
||||
{/if}
|
||||
<span class="rail-chip-dots" aria-hidden="true">
|
||||
{#each ex.sets as s, si (si)}
|
||||
<span
|
||||
class="rail-dot"
|
||||
class:filled={s.completed}
|
||||
class:current={isActive && si === activeSetIdx && !s.completed}
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
{#if complete}
|
||||
<span class="rail-chip-count done" aria-label="Exercise complete">
|
||||
<Check size={14} strokeWidth={2.8} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="rail-chip-count">{done}/{ex.sets.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if onRemove}
|
||||
<button
|
||||
class="rail-chip-remove"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(i); }}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
type="button"
|
||||
draggable="false"
|
||||
ondragstart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X size={14} strokeWidth={2.6} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<button class="rail-add" onclick={onAddExercise} type="button">
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
<span>{addLabel}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workout-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1rem 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Header: elapsed + progress */
|
||||
.rail-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.25rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.rail-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-title :global(input) {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.1rem 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
outline: none;
|
||||
}
|
||||
.rail-title :global(input::placeholder) {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-title :global(input:focus) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-pause {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 140ms, color 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-pause:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.rail-pause:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: 1;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-elapsed.paused {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.rail-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.rail-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--color-primary), transparent 40%), var(--color-primary));
|
||||
transition: width 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.rail-progress-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.rail-progress-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
margin-inline: 0.1rem;
|
||||
}
|
||||
|
||||
/* Chip list */
|
||||
.rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 100px;
|
||||
}
|
||||
.rail-list::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-text-tertiary), transparent 50%);
|
||||
}
|
||||
|
||||
/* Row wrapper holds chip + remove button, carries drag state */
|
||||
.rail-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-item[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
.rail-item[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.rail-item.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.rail-item.drop-above::before,
|
||||
.rail-item.drop-below::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary), transparent 70%);
|
||||
}
|
||||
.rail-item.drop-above::before {
|
||||
top: -3px;
|
||||
}
|
||||
.rail-item.drop-below::after {
|
||||
bottom: -3px;
|
||||
}
|
||||
|
||||
.rail-chip {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.55rem 0.55rem 0.45rem;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms, border-color 140ms, transform 120ms;
|
||||
}
|
||||
.rail-chip:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.rail-chip:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.rail-chip.active {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 82%);
|
||||
border-color: transparent;
|
||||
}
|
||||
.rail-chip.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 76%);
|
||||
}
|
||||
.rail-chip.complete {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.rail-chip.complete.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* × remove — overlays the set counter on hover (same spot) */
|
||||
.rail-chip-remove {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.4rem;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 100px;
|
||||
color: var(--nord11);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms, background 140ms, transform 120ms;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-item:hover .rail-chip-remove,
|
||||
.rail-chip-remove:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.rail-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--nord11), transparent 82%);
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
.rail-chip-remove:active {
|
||||
transform: translateY(-50%) scale(0.94);
|
||||
}
|
||||
|
||||
.rail-chip-index {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.rail-chip.active .rail-chip-index {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rail-chip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-chip-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Starting weight hint — "what to rack" */
|
||||
.rail-chip-load {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-chip.active .rail-chip-load {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.rail-chip.complete .rail-chip-load {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, currentColor, transparent 60%);
|
||||
}
|
||||
|
||||
.rail-chip-dots {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rail-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 100px;
|
||||
background: var(--color-border);
|
||||
transition: background 180ms, transform 180ms;
|
||||
}
|
||||
.rail-dot.filled {
|
||||
background: color-mix(in srgb, var(--color-primary), transparent 15%);
|
||||
}
|
||||
.rail-dot.current {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.3);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* Set counter — visible by default, fades out when × takes over on hover */
|
||||
.rail-chip-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.55rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
transition: opacity 140ms;
|
||||
}
|
||||
.rail-chip.active .rail-chip-count {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
/* Completed exercise: matches the set-complete check button from SetTable */
|
||||
.rail-chip-count.done {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--nord14);
|
||||
background: var(--nord14);
|
||||
color: white;
|
||||
}
|
||||
.rail-item:hover .rail-chip-count {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Add exercise button */
|
||||
.rail-add {
|
||||
all: unset;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms, color 140ms;
|
||||
}
|
||||
.rail-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow viewports: vertical list, compact chip, dots inline next to name */
|
||||
@media (max-width: 899px) {
|
||||
.workout-rail {
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0.75rem 0.6rem;
|
||||
}
|
||||
/* Title full-width row, timer + progress share a second row */
|
||||
.rail-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"title title"
|
||||
"timer progress";
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.5rem;
|
||||
padding: 0 0.15rem 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-title { grid-area: title; }
|
||||
.rail-timer-row {
|
||||
grid-area: timer;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rail-elapsed {
|
||||
flex: initial;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.rail-progress {
|
||||
grid-area: progress;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Dots jump up next to the name; load sits below on its own row */
|
||||
.rail-chip-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"name dots"
|
||||
"load load";
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.1rem;
|
||||
align-items: center;
|
||||
}
|
||||
.rail-chip-name { grid-area: name; }
|
||||
.rail-chip-dots { grid-area: dots; flex-wrap: nowrap; }
|
||||
.rail-chip-load { grid-area: load; }
|
||||
.rail-chip {
|
||||
padding: 0.5rem 0.55rem 0.5rem 0.4rem;
|
||||
}
|
||||
/* Scrollable only on mobile — desktop lets the rail grow */
|
||||
.rail-list {
|
||||
max-height: 10.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Smaller completion checkmark */
|
||||
.rail-chip-count {
|
||||
min-width: 1.25rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.rail-chip-count.done {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.rail-chip-count.done :global(svg) {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
.rail-add {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -111,7 +111,7 @@
|
||||
<UserHeader {user} />
|
||||
{/snippet}
|
||||
|
||||
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex || isExercisesIndex ? '1400px' : null}>
|
||||
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex || isExercisesIndex || isOnActivePage ? '1400px' : null}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
const isEn = $derived(lang === 'en');
|
||||
const sl = $derived(fitnessSlugs(lang));
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
@@ -35,7 +36,8 @@
|
||||
import { queueSession } from '$lib/offline/fitnessQueue';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte';
|
||||
import WorkoutFocusCard from '$lib/components/fitness/WorkoutFocusCard.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -50,6 +52,82 @@
|
||||
/** @type {Record<string, Array<Record<string, any>>>} */
|
||||
let previousData = $state({});
|
||||
|
||||
/** User-pinned exercise index for the focus pane (null = auto-follow first incomplete) */
|
||||
/** @type {number | null} */
|
||||
let focusedIdx = $state(null);
|
||||
|
||||
const autoCurrentIdx = $derived.by(() => {
|
||||
const exs = workout.exercises;
|
||||
for (let i = 0; i < exs.length; i++) {
|
||||
if (exs[i].sets.some((/** @type {any} */ s) => !s.completed)) return i;
|
||||
}
|
||||
return Math.max(0, exs.length - 1);
|
||||
});
|
||||
|
||||
const activeIdx = $derived.by(() => {
|
||||
const exs = workout.exercises;
|
||||
if (exs.length === 0) return -1;
|
||||
if (focusedIdx != null && focusedIdx >= 0 && focusedIdx < exs.length) return focusedIdx;
|
||||
return autoCurrentIdx;
|
||||
});
|
||||
|
||||
const activeExercise = $derived(activeIdx >= 0 ? workout.exercises[activeIdx] : null);
|
||||
const activeExerciseMeta = $derived(activeExercise ? getExerciseById(activeExercise.exerciseId, lang) : null);
|
||||
|
||||
const activeSetIdx = $derived.by(() => {
|
||||
const ex = activeExercise;
|
||||
if (!ex || !ex.sets.length) return 0;
|
||||
for (let i = 0; i < ex.sets.length; i++) {
|
||||
if (!ex.sets[i].completed) return i;
|
||||
}
|
||||
return ex.sets.length;
|
||||
});
|
||||
|
||||
const activeExDoneCount = $derived(activeExercise ? activeExercise.sets.filter((/** @type {any} */ s) => s.completed).length : 0);
|
||||
|
||||
const workoutSetsDone = $derived(
|
||||
workout.exercises.reduce(
|
||||
(/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.filter((/** @type {any} */ s) => s.completed).length,
|
||||
0
|
||||
)
|
||||
);
|
||||
const workoutSetsTotal = $derived(
|
||||
workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0)
|
||||
);
|
||||
|
||||
/** @param {number} idx */
|
||||
function setFocus(idx) { focusedIdx = idx; }
|
||||
|
||||
/**
|
||||
* Reorder an exercise from one index to another, adjusting focus to follow.
|
||||
* @param {number} fromIdx
|
||||
* @param {number} toIdx
|
||||
*/
|
||||
function reorderExercise(fromIdx, toIdx) {
|
||||
if (fromIdx === toIdx) return;
|
||||
const dir = fromIdx < toIdx ? 1 : -1;
|
||||
let i = fromIdx;
|
||||
while (i !== toIdx) {
|
||||
workout.moveExercise(i, dir);
|
||||
i += dir;
|
||||
}
|
||||
// Track the moved exercise if it was focused
|
||||
if (focusedIdx === fromIdx) {
|
||||
focusedIdx = toIdx;
|
||||
} else if (focusedIdx != null) {
|
||||
if (fromIdx < focusedIdx && toIdx >= focusedIdx) focusedIdx = focusedIdx - 1;
|
||||
else if (fromIdx > focusedIdx && toIdx <= focusedIdx) focusedIdx = focusedIdx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} idx */
|
||||
function removeExerciseFromRail(idx) {
|
||||
workout.removeExercise(idx);
|
||||
// Unpin focus so auto-current takes over (handles removing the focused one)
|
||||
if (focusedIdx === idx) focusedIdx = null;
|
||||
else if (focusedIdx != null && idx < focusedIdx) focusedIdx = focusedIdx - 1;
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
let completionData = $state(null);
|
||||
|
||||
@@ -1436,15 +1514,17 @@
|
||||
|
||||
{:else if workout.active}
|
||||
<div class="active-workout">
|
||||
<input
|
||||
class="workout-name-input"
|
||||
type="text"
|
||||
bind:value={nameInput}
|
||||
onfocus={() => { nameEditing = true; }}
|
||||
onblur={() => { nameEditing = false; workout.name = nameInput; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
|
||||
placeholder={t('workout_name_placeholder', lang)}
|
||||
/>
|
||||
{#snippet workoutTitle()}
|
||||
<input
|
||||
class="workout-name-input"
|
||||
type="text"
|
||||
bind:value={nameInput}
|
||||
onfocus={() => { nameEditing = true; }}
|
||||
onblur={() => { nameEditing = false; workout.name = nameInput; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
|
||||
placeholder={t('workout_name_placeholder', lang)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if gps.available && hasCardioExercise()}
|
||||
<div class="gps-section">
|
||||
@@ -1555,89 +1635,105 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each workout.exercises as ex, exIdx (exIdx)}
|
||||
{@const exMetrics = getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
||||
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
|
||||
<div class="exercise-block">
|
||||
<div class="exercise-header">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
<div class="exercise-header-actions">
|
||||
<button class="move-exercise" disabled={exIdx === 0} onclick={() => workout.moveExercise(exIdx, -1)} aria-label="Move up">
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button class="move-exercise" disabled={exIdx === workout.exercises.length - 1} onclick={() => workout.moveExercise(exIdx, 1)} aria-label="Move down">
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="remove-exercise"
|
||||
onclick={() => workout.removeExercise(exIdx)}
|
||||
aria-label="Remove exercise"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<div class="workout-grid">
|
||||
<WorkoutRail
|
||||
title={workoutTitle}
|
||||
exercises={workout.exercises}
|
||||
activeIdx={activeIdx}
|
||||
activeSetIdx={activeSetIdx}
|
||||
elapsedLabel={formatElapsed(workout.elapsedSeconds)}
|
||||
paused={workout.paused}
|
||||
syncStatus={sync.status}
|
||||
setsDone={workoutSetsDone}
|
||||
setsTotal={workoutSetsTotal}
|
||||
addLabel={t('add_exercise', lang)}
|
||||
pauseLabel={isEn ? 'Pause' : 'Pause'}
|
||||
resumeLabel={isEn ? 'Resume' : 'Fortsetzen'}
|
||||
removeLabel={isEn ? 'Remove exercise' : 'Übung entfernen'}
|
||||
previousData={previousData}
|
||||
onPauseToggle={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()}
|
||||
onFocus={setFocus}
|
||||
onAddExercise={() => showPicker = true}
|
||||
onRemove={removeExerciseFromRail}
|
||||
onReorder={reorderExercise}
|
||||
/>
|
||||
|
||||
<main class="workout-stage">
|
||||
{#if activeExercise}
|
||||
{@const exMetrics = getExerciseMetrics(getExerciseById(activeExercise.exerciseId))}
|
||||
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
|
||||
|
||||
<WorkoutFocusCard
|
||||
exerciseId={activeExercise.exerciseId}
|
||||
bodyPart={activeExerciseMeta?.localBodyPart ?? null}
|
||||
equipment={activeExerciseMeta?.localEquipment ?? null}
|
||||
detailsHref={`/fitness/${sl.exercises}/${activeExercise.exerciseId}`}
|
||||
detailsLabel={isEn ? 'Exercise details' : 'Übungsdetails'}
|
||||
exerciseIndex={activeIdx}
|
||||
totalExercises={workout.exercises.length}
|
||||
sets={activeExercise.sets}
|
||||
activeSetIdx={activeSetIdx}
|
||||
labels={{
|
||||
exerciseOf: (i, n) => isEn ? `Exercise ${i} of ${n}` : `Übung ${i} von ${n}`,
|
||||
setOf: (i, n) => isEn ? `Set ${i} of ${n}` : `Satz ${i} von ${n}`,
|
||||
done: (n) => isEn ? `${n}/${n} complete` : `${n}/${n} erledigt`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="exercise-block focused">
|
||||
<SetTable
|
||||
sets={activeExercise.sets}
|
||||
previousSets={previousData[activeExercise.exerciseId] ?? []}
|
||||
metrics={exMetrics}
|
||||
editable={true}
|
||||
timedHold={isDurationOnly}
|
||||
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === activeIdx ? workout.restSetIdx : -1}
|
||||
restSeconds={workout.restTimerSeconds}
|
||||
restTotal={workout.restTimerTotal}
|
||||
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === activeIdx ? workout.holdSetIdx : -1}
|
||||
holdSeconds={workout.holdTimerSeconds}
|
||||
holdTotal={workout.holdTimerTotal}
|
||||
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
|
||||
onRestSkip={cancelRest}
|
||||
onHoldSkip={() => workout.cancelHoldTimer()}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
const ex = activeExercise;
|
||||
if (!ex) return;
|
||||
const set = ex.sets[setIdx];
|
||||
if (workout.holdTimerActive && workout.holdExerciseIdx === activeIdx && workout.holdSetIdx === setIdx) {
|
||||
workout.cancelHoldTimer();
|
||||
return;
|
||||
}
|
||||
if (isDurationOnly && set?.duration && !set.completed) {
|
||||
workout.startHoldTimer(Math.round(set.duration * 60), activeIdx, setIdx);
|
||||
} else {
|
||||
workout.toggleSetComplete(activeIdx, setIdx);
|
||||
if (ex.sets[setIdx]?.completed) {
|
||||
workout.startRestTimer(ex.restTime, activeIdx, setIdx);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onRemove={(setIdx) => workout.removeSet(activeIdx, setIdx)}
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => workout.addSet(activeIdx)}>
|
||||
{t('add_set', lang)}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-stage">
|
||||
<p>{isEn ? 'Add an exercise to get started.' : 'Füge eine Übung hinzu, um zu starten.'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="workout-actions">
|
||||
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||
{t('cancel_workout', lang)}
|
||||
</button>
|
||||
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
|
||||
</div>
|
||||
|
||||
<SetTable
|
||||
sets={ex.sets}
|
||||
previousSets={previousData[ex.exerciseId] ?? []}
|
||||
metrics={exMetrics}
|
||||
editable={true}
|
||||
timedHold={isDurationOnly}
|
||||
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === exIdx ? workout.restSetIdx : -1}
|
||||
restSeconds={workout.restTimerSeconds}
|
||||
restTotal={workout.restTimerTotal}
|
||||
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === exIdx ? workout.holdSetIdx : -1}
|
||||
holdSeconds={workout.holdTimerSeconds}
|
||||
holdTotal={workout.holdTimerTotal}
|
||||
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
|
||||
onRestSkip={cancelRest}
|
||||
onHoldSkip={() => workout.cancelHoldTimer()}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
const set = ex.sets[setIdx];
|
||||
// If hold timer is running for this set, cancel it
|
||||
if (workout.holdTimerActive && workout.holdExerciseIdx === exIdx && workout.holdSetIdx === setIdx) {
|
||||
workout.cancelHoldTimer();
|
||||
return;
|
||||
}
|
||||
if (isDurationOnly && set?.duration && !set.completed) {
|
||||
// Start hold countdown — completion happens automatically
|
||||
workout.startHoldTimer(Math.round(set.duration * 60), exIdx, setIdx);
|
||||
} else {
|
||||
workout.toggleSetComplete(exIdx, setIdx);
|
||||
if (ex.sets[setIdx]?.completed) {
|
||||
workout.startRestTimer(ex.restTime, exIdx, setIdx);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
|
||||
{t('add_set', lang)}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="workout-actions">
|
||||
<button class="add-exercise-btn" onclick={() => showPicker = true}>
|
||||
{t('add_exercise', lang)}
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||
{t('cancel_workout', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workout-bottombar">
|
||||
<div class="topbar-left">
|
||||
<button class="pause-btn" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
|
||||
{#if workout.paused}<Play size={16} />{:else}<Pause size={16} />{/if}
|
||||
</button>
|
||||
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
|
||||
<SyncIndicator status={sync.status} />
|
||||
</div>
|
||||
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1883,69 +1979,44 @@
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workout-bottombar {
|
||||
/* Active workout: sidebar rail + focus stage */
|
||||
.workout-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--color-bg-primary);
|
||||
z-index: 10;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.topbar-left {
|
||||
.workout-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.pause-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
@media (min-width: 900px) {
|
||||
.workout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.workout-grid :global(.workout-rail) {
|
||||
position: sticky;
|
||||
top: calc(8.5rem + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.workout-grid {
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
.empty-stage {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pause-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.elapsed.paused {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.finish-btn {
|
||||
background: var(--color-primary);
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.workout-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: inherit;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.workout-name-input:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.exercise-block {
|
||||
@@ -1954,6 +2025,11 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
.exercise-block.focused {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0.25rem 0 0;
|
||||
}
|
||||
.exercise-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -2012,9 +2088,9 @@
|
||||
|
||||
.workout-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
.add-exercise-btn {
|
||||
display: flex;
|
||||
@@ -2032,20 +2108,44 @@
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
/* Cancel: ghost style (less prominent) — matches body-parts .ghost pattern */
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nord11);
|
||||
border-radius: 10px;
|
||||
color: var(--nord11);
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
cursor: pointer;
|
||||
transition: color 150ms, background 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
background: rgba(191, 97, 106, 0.1);
|
||||
color: var(--nord11);
|
||||
background: color-mix(in srgb, var(--nord11), transparent 92%);
|
||||
}
|
||||
/* Finish: primary, dominant */
|
||||
.finish-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 140ms, transform 120ms, box-shadow 140ms;
|
||||
}
|
||||
.finish-btn:hover {
|
||||
background: var(--color-primary-hover, var(--color-primary));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.finish-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* GPS section */
|
||||
|
||||
Reference in New Issue
Block a user