From ad98db7f7c9de6786c2cb42b81281c0c75f6fea7 Mon Sep 17 00:00:00 2001 From: Timo Boomers Date: Fri, 18 Jul 2025 10:06:24 +0200 Subject: [PATCH] Very basic workout player --- assets/css/main.css | 51 ++++++++++++----- assets/js/play.js | 63 +++++++++++++++++++++ src/layouts/empty.rs | 1 + src/models/exercises.rs | 2 +- src/models/workouts.rs | 2 +- src/pages/workouts/id.rs | 101 +++------------------------------- src/pages/workouts/id/play.rs | 79 ++++++++++++++++++++++++++ 7 files changed, 190 insertions(+), 109 deletions(-) create mode 100644 assets/js/play.js create mode 100644 src/pages/workouts/id/play.rs diff --git a/assets/css/main.css b/assets/css/main.css index 54d5b87..a29f058 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -19,6 +19,12 @@ --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; --font-weight-bold: 700; --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); @@ -956,18 +962,15 @@ justify-content: flex-end; gap: calc(0.25rem * 2); } - .mt-2 { - margin-top: calc(var(--spacing) * 2); - } .mt-3 { margin-top: calc(var(--spacing) * 3); } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } .mr-1 { margin-right: calc(var(--spacing) * 1); } - .mr-3 { - margin-right: calc(var(--spacing) * 3); - } .fieldset-legend { margin-bottom: calc(0.25rem * -1); display: flex; @@ -1028,12 +1031,12 @@ width: 1.6em; height: 1.6em; } + .h-48 { + height: calc(var(--spacing) * 48); + } .h-\[1\.2em\] { height: 1.2em; } - .h-\[92px\] { - height: 92px; - } .h-full { height: 100%; } @@ -1049,15 +1052,9 @@ .w-56 { width: calc(var(--spacing) * 56); } - .w-\[1\.5em\] { - width: 1.5em; - } .w-\[1\.6em\] { width: 1.6em; } - .w-\[1\.7em\] { - width: 1.7em; - } .w-full { width: 100%; } @@ -1098,6 +1095,9 @@ .gap-2 { gap: calc(var(--spacing) * 2); } + .gap-3 { + gap: calc(var(--spacing) * 3); + } .space-y-1 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -1139,10 +1139,28 @@ .pb-6 { padding-bottom: calc(var(--spacing) * 6); } + .text-center { + text-align: center; + } + .text-right { + text-align: right; + } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-6xl { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); @@ -1159,6 +1177,9 @@ --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .whitespace-nowrap { + white-space: nowrap; + } .opacity-50 { opacity: 50%; } diff --git a/assets/js/play.js b/assets/js/play.js new file mode 100644 index 0000000..dcb30ff --- /dev/null +++ b/assets/js/play.js @@ -0,0 +1,63 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('exercisePlayer', () => { + return { + timer: null, + counter: 0, + timeLeft: 0, + currentPosition: 0, + exercises: [], + isPaused: false, + init() { + this.exercises = window.exerciseData; + }, + start() { + if (!this.isPaused) { + this.timeLeft = this.exercises[this.currentPosition].time; + this.counter = 0; + } + + this.timer = setInterval(() => { + if (this.timeLeft === 0) { + this.next() + }; + + this.counter++; + this.timeLeft--; + }, 1000); + }, + destroy() { + // Detach the handler, avoiding memory and side-effect leakage + if (this.timer) { + clearInterval(this.timer); + } + + if (!this.isPaused) { + this.counter = 0; + this.timeLeft = 0; + } + }, + next() { + this.destroy() + this.currentPosition++; + + if (this.exercises[this.currentPosition].exercise_type === "Time") { + this.start() + } + }, + previous() { + this.destroy(); + this.currentPosition--; + }, + + pause() { + if (this.isPaused) { + this.start(); + this.isPaused = false; + } else { + this.isPaused = true; + this.destroy() + } + } + } + }) +}) diff --git a/src/layouts/empty.rs b/src/layouts/empty.rs index 914a71f..5f807b5 100644 --- a/src/layouts/empty.rs +++ b/src/layouts/empty.rs @@ -6,6 +6,7 @@ pub fn empty(content: Markup, name: &str) -> Markup { head { script src="/assets/lib/htmx.min.js" { } script defer src="/assets/lib/alpine.min.js" { } + script src="/assets/js/play.js" {} link rel="stylesheet" href="/assets/css/main.css" { } title { (name) " - Timo's Workouts" diff --git a/src/models/exercises.rs b/src/models/exercises.rs index 77275f3..95f847d 100644 --- a/src/models/exercises.rs +++ b/src/models/exercises.rs @@ -20,7 +20,7 @@ pub struct ExerciseFull { pub muscles: Vec, } -#[derive(sqlx::Type, serde::Deserialize, Debug)] +#[derive(sqlx::Type, serde::Deserialize, serde::Serialize, Debug)] #[sqlx(type_name = "exercise_variant", rename_all = "lowercase")] pub enum ExerciseVariant { Time, diff --git a/src/models/workouts.rs b/src/models/workouts.rs index c4cf82a..76786b4 100644 --- a/src/models/workouts.rs +++ b/src/models/workouts.rs @@ -21,7 +21,7 @@ pub struct WorkoutExercise { pub time: Option, } -#[derive(Debug, FromRow)] +#[derive(Debug, FromRow, serde::Serialize)] pub struct WorkoutExerciseFull { pub exercise_id: Uuid, pub exercise_type: ExerciseVariant, diff --git a/src/pages/workouts/id.rs b/src/pages/workouts/id.rs index 951624b..d52248a 100644 --- a/src/pages/workouts/id.rs +++ b/src/pages/workouts/id.rs @@ -4,20 +4,19 @@ use crate::{ util::AppState, }; use axum::{ - Form, Router, + Router, extract::{Path, State}, - http::HeaderMap, - response::IntoResponse, routing::get, }; use maud::{Markup, html}; -use serde::Deserialize; use uuid::Uuid; +mod play; + pub fn routes() -> Router { Router::new() - .route("/", get(page).delete(delete).put(submit_edit)) - .route("/edit", get(edit)) + .route("/", get(page)) + .nest("/play", play::routes()) } async fn page(State(state): State, Path(id): Path) -> Result { @@ -41,8 +40,6 @@ async fn page(State(state): State, Path(id): Path) -> Result, Path(id): Path) -> Result Markup { h3 { (exercise.name) } p class="text-sm opacity-50" { (exercise.description) } } - div class="ml-auto flex items-center" { + div class="ml-auto flex gap-2 items-center" { @match exercise.exercise_type { ExerciseVariant::Time => { div { (exercise.time.unwrap_or(-1))"s" } }, ExerciseVariant::Number => { div { - div { (exercise.sets.unwrap_or(-1)) " sets" } - div { (exercise.reps.unwrap_or(-1)) " reps" } + div class="whitespace-nowrap text-right" { (exercise.sets.unwrap_or(-1)) " sets" } + div class="whitespace-nowrap text-right" { (exercise.reps.unwrap_or(-1)) " reps" } } }, ExerciseVariant::Failure => { @@ -91,83 +88,3 @@ fn display_exercise(exercise: WorkoutExerciseFull) -> Markup { } } } - -async fn delete( - State(state): State, - Path(id): Path, -) -> Result { - sqlx::query_as!( - crate::models::Exercise, - "DELETE FROM workouts WHERE workout_id = $1", - id - ) - .execute(&state.pool) - .await?; - - let mut headers = HeaderMap::new(); - headers.insert("hx-location", "/workouts".parse().unwrap()); - - Ok((headers, html! {})) -} - -async fn edit( - State(state): State, - Path(id): Path, -) -> Result { - let exercise = sqlx::query_as!( - crate::models::Exercise, - "SELECT exercise_id, name, description FROM exercises WHERE exercise_id = $1", - id - ) - .fetch_one(&state.pool) - .await?; - - let content = html! { - h1 class="mb-5" { "Edit Exercise" } - - form hx-put={"/exercises/" (exercise.exercise_id)} class="space-y-1" { - fieldset class="fieldset" { - legend class="fieldset-legend" { "Name" } - input required="true" name="name" class="input" value={(exercise.name)} {} - } - - fieldset class="fieldset" { - legend class="fieldset-legend" { "Description" } - textarea required="true" name="description" class="textarea" { (exercise.description) } - } - - input type="submit" class="btn" value="save" { } - } - }; - - Ok(layouts::desktop(content, "Edit Exercises", true)) -} - -#[derive(Deserialize, Debug)] -struct FormData { - name: String, - description: String, -} - -async fn submit_edit( - State(state): State, - Path(id): Path, - Form(form): Form, -) -> Result { - sqlx::query_as!( - crate::models::Exercise, - "UPDATE exercises SET name = $1, description = $2 WHERE exercise_id = $3", - form.name, - form.description, - id - ) - .execute(&state.pool) - .await?; - - let mut headers = HeaderMap::new(); - - let location = format!("/exercises/{}", id); - headers.insert("hx-location", location.parse().unwrap()); - - Ok((headers, html! {})) -} diff --git a/src/pages/workouts/id/play.rs b/src/pages/workouts/id/play.rs new file mode 100644 index 0000000..d527ad2 --- /dev/null +++ b/src/pages/workouts/id/play.rs @@ -0,0 +1,79 @@ +use crate::{ + AppError, layouts, + models::{exercises::ExerciseVariant, workouts::WorkoutExerciseFull}, + util::AppState, +}; +use axum::{ + Router, + extract::{Path, State}, + routing::get, +}; +use maud::{Markup, PreEscaped, html}; +use uuid::Uuid; + +pub fn routes() -> Router { + Router::new().route("/", get(page)) +} + +async fn page(State(state): State, Path(id): Path) -> Result { + let exercises = sqlx::query_as!( + WorkoutExerciseFull, + r#" + SELECT exercises.exercise_id, exercise_type as "exercise_type:ExerciseVariant", sets, reps, time, name, description, position + FROM workout_exercises + JOIN exercises ON workout_exercises.exercise_id = exercises.exercise_id + WHERE workout_id = $1 + ORDER BY position + "#, + &id, + ).fetch_all(&state.pool).await?; + + let exercises_json = serde_json::to_string(&exercises).unwrap(); + + let content = html! { + script { + "window.exerciseData = " (PreEscaped(exercises_json)) ";" + } + div x-data="exercisePlayer" { + @for exercise in exercises { + (display_exercise(exercise)) + } + } + }; + + Ok(layouts::desktop(content, "Working out", true)) +} + +fn display_exercise(exercise: WorkoutExerciseFull) -> Markup { + html! { + div x-bind:class={"{ 'hidden': currentPosition !="(exercise.position)" }"} { + div class="flex justify-center items-center h-48" { + @match exercise.exercise_type { + ExerciseVariant::Time => div { + div { + span class="font-bold text-6xl" x-text="timeLeft" {} + span class="" { " / " } + span class="" {(exercise.time.unwrap_or(-1))} + } + }, + ExerciseVariant::Number => div { + div { span class="font-bold text-6xl" {(exercise.sets.unwrap_or(-1))} " sets" } + div { span class="font-bold text-6xl" {(exercise.reps.unwrap_or(-1))} " reps" } + }, + ExerciseVariant::Failure => div { + div { span class="font-bold text-6xl" { "Failure" } } + } + } + } + div class="flex justify-center gap-3" { + button class="btn" x-on:click="previous()" { "Previous" } + button class="btn" disabled[exercise.time.is_none()] x-on:click="pause()" { "Pause" } + button class="btn" x-on:click="next()" { "Next" } + } + div class="mt-5" { + h2 class="text-center font-bold text-4xl" { (exercise.name) } + p class="text-center desc" { (exercise.description) } + } + } + } +}