|
1 | 1 | <script> |
2 | | - import { timerState } from '../lib/stores.js' |
3 | | - import { t } from '../lib/i18n.js' |
| 2 | + import { timerState, preferences } from '../lib/stores.js' |
| 3 | + import { t, getLocalizedName } from '../lib/i18n.js' |
| 4 | + import { getProgramById } from '../lib/programs/registry.js' |
4 | 5 |
|
5 | | - let { onStart, onPause, onResume, onStop, onNext, onReset } = $props() |
| 6 | + let { |
| 7 | + onStart, |
| 8 | + onPause, |
| 9 | + onResume, |
| 10 | + onStop, |
| 11 | + onRestart, |
| 12 | + onNextSeries, |
| 13 | + onNext, |
| 14 | + onReset, |
| 15 | + bottomExtra = undefined, |
| 16 | + } = $props() |
| 17 | +
|
| 18 | + let program = $derived(getProgramById($timerState.programId)) |
| 19 | + let stage = $derived(program?.stages[$timerState.stageIndex]) |
| 20 | + let lang = $derived($preferences.lang) |
| 21 | + let reason = $derived($timerState.stoppedReason) |
| 22 | +
|
| 23 | + // Label for the next exercise in the current stage (unnamed exercises fall back to numbered label). |
| 24 | + function exerciseLabelFor(idx) { |
| 25 | + return `${$t('exercise')} ${idx + 1}` |
| 26 | + } |
| 27 | +
|
| 28 | + // Slot 3 descriptor: { kind: 'stage' | 'exercise', name } |
| 29 | + // null when there is no forward target (program end). |
| 30 | + let slot3 = $derived.by(() => { |
| 31 | + if (!program || !stage) return null |
| 32 | + if (reason === 'programComplete') return null |
| 33 | +
|
| 34 | + // Stage boundary crossed (or about to cross) |
| 35 | + if (reason === 'stageComplete') { |
| 36 | + const nextStage = program.stages[$timerState.stageIndex + 1] |
| 37 | + if (!nextStage) return null |
| 38 | + return { kind: 'stage', name: getLocalizedName(nextStage.name, lang) ?? '' } |
| 39 | + } |
| 40 | +
|
| 41 | + // Next exercise exists in the current stage |
| 42 | + const nextExIdx = $timerState.exerciseIndex + 1 |
| 43 | + if (stage.exercises[nextExIdx]) { |
| 44 | + return { kind: 'exercise', name: exerciseLabelFor(nextExIdx) } |
| 45 | + } |
| 46 | +
|
| 47 | + // No more exercises in stage — show next stage as the forward action |
| 48 | + const nextStage = program.stages[$timerState.stageIndex + 1] |
| 49 | + if (nextStage) { |
| 50 | + return { kind: 'stage', name: getLocalizedName(nextStage.name, lang) ?? '' } |
| 51 | + } |
| 52 | +
|
| 53 | + return null |
| 54 | + }) |
| 55 | +
|
| 56 | + // Next-series button is visible only when a next series actually exists in the current exercise. |
| 57 | + // - After 'seriesComplete' seriesIndex was advanced, so it points at the upcoming series. |
| 58 | + // - After 'aborted' seriesIndex still points at the aborted series, so "next" = seriesIndex + 1. |
| 59 | + let showNextSeries = $derived.by(() => { |
| 60 | + const exercise = stage?.exercises[$timerState.exerciseIndex] |
| 61 | + if (!exercise) return false |
| 62 | + if (reason === 'seriesComplete') return $timerState.seriesIndex < exercise.seriesCount |
| 63 | + if (reason === 'aborted') return $timerState.seriesIndex + 1 < exercise.seriesCount |
| 64 | + return false |
| 65 | + }) |
| 66 | +
|
| 67 | + // Exactly one primary (green) button per phase. Which slot? |
| 68 | + let primary = $derived.by(() => { |
| 69 | + if (reason === 'programComplete') return null |
| 70 | + if (reason === 'stageComplete' || reason === 'exerciseComplete') return 'slot3' |
| 71 | + if (showNextSeries) return 'slot2' |
| 72 | + return 'slot3' |
| 73 | + }) |
6 | 74 | </script> |
7 | 75 |
|
8 | 76 | <div class="control-bar"> |
9 | 77 | <div class="main-actions"> |
10 | | - {#if $timerState.phase === 'idle' || $timerState.phase === 'stopped'} |
11 | | - <button class="btn-action btn-start" onclick={onStart}> |
| 78 | + {#if $timerState.phase === 'idle'} |
| 79 | + <button class="btn-action primary" onclick={onStart}> |
12 | 80 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> |
13 | | - {$timerState.phase === 'stopped' ? $t('startNextSeries') : $t('start')} |
| 81 | + {$t('start')} |
| 82 | + </button> |
| 83 | +
|
| 84 | + {:else if $timerState.phase === 'stopped' && reason === 'programComplete'} |
| 85 | + <div class="program-complete-label">{$t('programComplete')}</div> |
| 86 | +
|
| 87 | + {:else if $timerState.phase === 'stopped'} |
| 88 | + <!-- Slot 1: Restart series (always shown while stopped) --> |
| 89 | + <button |
| 90 | + class="btn-action btn-restart" |
| 91 | + class:primary={primary === 'slot1'} |
| 92 | + onclick={onRestart} |
| 93 | + > |
| 94 | + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| 95 | + <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> |
| 96 | + <path d="M3 3v5h5"/> |
| 97 | + </svg> |
| 98 | + {$t('restartSeries')} |
14 | 99 | </button> |
15 | | - {#if $timerState.phase === 'stopped'} |
16 | | - <button class="btn-action btn-next" onclick={onNext}> |
| 100 | +
|
| 101 | + <!-- Slot 2: Next series --> |
| 102 | + {#if showNextSeries} |
| 103 | + <button |
| 104 | + class="btn-action btn-next-series" |
| 105 | + class:primary={primary === 'slot2'} |
| 106 | + onclick={onNextSeries} |
| 107 | + > |
| 108 | + <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> |
| 109 | + {$t('nextSeries')} |
| 110 | + </button> |
| 111 | + {/if} |
| 112 | +
|
| 113 | + <!-- Slot 3: Next exercise / Next stage --> |
| 114 | + {#if slot3} |
| 115 | + <button |
| 116 | + class="btn-action btn-next-context" |
| 117 | + class:primary={primary === 'slot3'} |
| 118 | + onclick={onNext} |
| 119 | + > |
17 | 120 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2-8.14L11.03 12 8 14.14V9.86zM16 6h2v12h-2z"/></svg> |
18 | | - {$t('nextExercise')} |
| 121 | + {slot3.kind === 'stage' ? $t('nextStageLabel') : $t('nextExerciseLabel')} |
19 | 122 | </button> |
20 | 123 | {/if} |
| 124 | +
|
21 | 125 | {:else if $timerState.phase === 'loading' || $timerState.phase === 'shooting'} |
22 | 126 | <button class="btn-action btn-pause" onclick={onPause}> |
23 | 127 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> |
|
27 | 131 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h12v12H6z"/></svg> |
28 | 132 | {$t('stop')} |
29 | 133 | </button> |
| 134 | +
|
30 | 135 | {:else if $timerState.phase === 'paused'} |
31 | | - <button class="btn-action btn-start" onclick={onResume}> |
| 136 | + <button class="btn-action primary" onclick={onResume}> |
32 | 137 | <svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> |
33 | 138 | {$t('resume')} |
34 | 139 | </button> |
|
39 | 144 | {/if} |
40 | 145 | </div> |
41 | 146 |
|
42 | | - <button class="btn-reset" onclick={onReset}> |
43 | | - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
44 | | - <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> |
45 | | - <path d="M3 3v5h5"/> |
46 | | - </svg> |
47 | | - {$t('reset')} |
48 | | - </button> |
| 147 | + {#if $timerState.phase === 'stopped' && reason !== 'programComplete' && slot3?.name} |
| 148 | + <div class="slot3-subline-row"> |
| 149 | + <div class="slot3-spacer"></div> |
| 150 | + {#if showNextSeries}<div class="slot3-spacer"></div>{/if} |
| 151 | + <div class="slot3-subline" class:slot3-subline-primary={primary === 'slot3'}>{slot3.name}</div> |
| 152 | + </div> |
| 153 | + {/if} |
| 154 | +
|
| 155 | + <div class="bottom-row"> |
| 156 | + <button class="btn-reset" onclick={onReset}> |
| 157 | + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| 158 | + <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> |
| 159 | + <path d="M3 3v5h5"/> |
| 160 | + </svg> |
| 161 | + {$t('reset')} |
| 162 | + </button> |
| 163 | + {#if bottomExtra} |
| 164 | + {@render bottomExtra()} |
| 165 | + {/if} |
| 166 | + </div> |
49 | 167 | </div> |
50 | 168 |
|
51 | 169 | <style> |
|
63 | 181 |
|
64 | 182 | .btn-action { |
65 | 183 | flex: 1; |
| 184 | + min-width: 0; |
66 | 185 | display: flex; |
67 | 186 | align-items: center; |
68 | 187 | justify-content: center; |
69 | 188 | gap: 0.5rem; |
70 | 189 | min-height: 54px; |
| 190 | + padding: 0 0.75rem; |
71 | 191 | font-size: 1rem; |
72 | 192 | font-weight: 700; |
73 | 193 | letter-spacing: 0.04em; |
74 | 194 | border-radius: var(--radius); |
| 195 | + background: var(--bg-surface); |
| 196 | + color: var(--text-primary); |
| 197 | + border: 1px solid rgba(255,255,255,0.08); |
75 | 198 | } |
76 | 199 |
|
| 200 | + .btn-action:hover { background: #252550; } |
| 201 | +
|
77 | 202 | .btn-action svg { |
78 | 203 | width: 1.25em; |
79 | 204 | height: 1.25em; |
80 | 205 | flex-shrink: 0; |
81 | 206 | } |
82 | 207 |
|
83 | | - .btn-start { |
| 208 | + /* Primary (green) — exactly one per phase */ |
| 209 | + .btn-action.primary { |
84 | 210 | background: var(--accent); |
85 | 211 | color: #0d0d1a; |
| 212 | + border-color: transparent; |
86 | 213 | } |
87 | 214 |
|
88 | | - .btn-start:hover { background: var(--accent-dim); } |
89 | | -
|
90 | | - .btn-pause { |
91 | | - background: var(--bg-surface); |
92 | | - color: var(--text-primary); |
93 | | - border: 1px solid rgba(255,255,255,0.08); |
94 | | - } |
95 | | -
|
96 | | - .btn-pause:hover { background: #252550; } |
| 215 | + .btn-action.primary:hover { background: var(--accent-dim); } |
97 | 216 |
|
| 217 | + /* Stop button keeps its red treatment regardless of primary */ |
98 | 218 | .btn-stop { |
99 | 219 | background: rgba(244, 67, 54, 0.15); |
100 | 220 | color: var(--danger); |
101 | 221 | border: 1px solid rgba(244, 67, 54, 0.3); |
102 | 222 | } |
103 | | -
|
104 | 223 | .btn-stop:hover { background: rgba(244, 67, 54, 0.25); } |
105 | 224 |
|
106 | | - .btn-next { |
107 | | - background: var(--bg-surface); |
108 | | - color: var(--text-primary); |
109 | | - border: 1px solid rgba(255,255,255,0.08); |
| 225 | + /* Subline row mirrors button row layout so the name centers under slot 3 */ |
| 226 | + .slot3-subline-row { |
| 227 | + display: flex; |
| 228 | + gap: 0.6rem; |
| 229 | + margin-top: -0.25rem; |
| 230 | + } |
| 231 | +
|
| 232 | + .slot3-spacer { |
| 233 | + flex: 1; |
| 234 | + min-width: 0; |
| 235 | + } |
| 236 | +
|
| 237 | + .slot3-subline { |
| 238 | + flex: 1; |
| 239 | + min-width: 0; |
| 240 | + text-align: center; |
| 241 | + font-size: 0.72rem; |
| 242 | + font-weight: 600; |
| 243 | + color: var(--text-secondary); |
| 244 | + opacity: 0.7; |
| 245 | + white-space: nowrap; |
| 246 | + overflow: hidden; |
| 247 | + text-overflow: ellipsis; |
| 248 | + } |
| 249 | +
|
| 250 | + .slot3-subline-primary { |
| 251 | + color: var(--accent); |
| 252 | + opacity: 0.85; |
| 253 | + } |
| 254 | +
|
| 255 | + /* Program-complete sentinel */ |
| 256 | + .program-complete-label { |
| 257 | + flex: 1; |
| 258 | + display: flex; |
| 259 | + align-items: center; |
| 260 | + justify-content: center; |
| 261 | + min-height: 54px; |
| 262 | + font-size: 1rem; |
| 263 | + font-weight: 700; |
| 264 | + color: var(--accent); |
| 265 | + letter-spacing: 0.04em; |
| 266 | + text-transform: uppercase; |
110 | 267 | } |
111 | 268 |
|
112 | | - .btn-next:hover { background: #252550; } |
| 269 | + .bottom-row { |
| 270 | + display: flex; |
| 271 | + align-items: center; |
| 272 | + justify-content: center; |
| 273 | + gap: 0.75rem; |
| 274 | + } |
113 | 275 |
|
114 | 276 | .btn-reset { |
115 | 277 | display: flex; |
116 | 278 | align-items: center; |
117 | 279 | justify-content: center; |
118 | 280 | gap: 0.4rem; |
119 | | - align-self: center; |
120 | 281 | padding: 0.4rem 1.25rem; |
121 | 282 | min-height: 36px; |
122 | 283 | font-size: 0.8rem; |
|
139 | 300 | color: var(--text-primary); |
140 | 301 | border-color: rgba(255,255,255,0.15); |
141 | 302 | } |
| 303 | +
|
| 304 | + /* Narrow phones: tighten buttons */ |
| 305 | + @media (max-width: 420px) { |
| 306 | + .btn-action { font-size: 0.92rem; padding: 0 0.5rem; } |
| 307 | + } |
142 | 308 | </style> |
0 commit comments