Very basic workout player
This commit is contained in:
@@ -19,6 +19,12 @@
|
|||||||
--text-xl--line-height: calc(1.75 / 1.25);
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
--text-2xl: 1.5rem;
|
--text-2xl: 1.5rem;
|
||||||
--text-2xl--line-height: calc(2 / 1.5);
|
--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;
|
--font-weight-bold: 700;
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
--default-mono-font-family: var(--font-mono);
|
--default-mono-font-family: var(--font-mono);
|
||||||
@@ -956,18 +962,15 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: calc(0.25rem * 2);
|
gap: calc(0.25rem * 2);
|
||||||
}
|
}
|
||||||
.mt-2 {
|
|
||||||
margin-top: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
.mt-3 {
|
.mt-3 {
|
||||||
margin-top: calc(var(--spacing) * 3);
|
margin-top: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.mt-5 {
|
||||||
|
margin-top: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.mr-1 {
|
.mr-1 {
|
||||||
margin-right: calc(var(--spacing) * 1);
|
margin-right: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
.mr-3 {
|
|
||||||
margin-right: calc(var(--spacing) * 3);
|
|
||||||
}
|
|
||||||
.fieldset-legend {
|
.fieldset-legend {
|
||||||
margin-bottom: calc(0.25rem * -1);
|
margin-bottom: calc(0.25rem * -1);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1028,12 +1031,12 @@
|
|||||||
width: 1.6em;
|
width: 1.6em;
|
||||||
height: 1.6em;
|
height: 1.6em;
|
||||||
}
|
}
|
||||||
|
.h-48 {
|
||||||
|
height: calc(var(--spacing) * 48);
|
||||||
|
}
|
||||||
.h-\[1\.2em\] {
|
.h-\[1\.2em\] {
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
}
|
}
|
||||||
.h-\[92px\] {
|
|
||||||
height: 92px;
|
|
||||||
}
|
|
||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -1049,15 +1052,9 @@
|
|||||||
.w-56 {
|
.w-56 {
|
||||||
width: calc(var(--spacing) * 56);
|
width: calc(var(--spacing) * 56);
|
||||||
}
|
}
|
||||||
.w-\[1\.5em\] {
|
|
||||||
width: 1.5em;
|
|
||||||
}
|
|
||||||
.w-\[1\.6em\] {
|
.w-\[1\.6em\] {
|
||||||
width: 1.6em;
|
width: 1.6em;
|
||||||
}
|
}
|
||||||
.w-\[1\.7em\] {
|
|
||||||
width: 1.7em;
|
|
||||||
}
|
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1095,9 @@
|
|||||||
.gap-2 {
|
.gap-2 {
|
||||||
gap: calc(var(--spacing) * 2);
|
gap: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.gap-3 {
|
||||||
|
gap: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.space-y-1 {
|
.space-y-1 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -1139,10 +1139,28 @@
|
|||||||
.pb-6 {
|
.pb-6 {
|
||||||
padding-bottom: calc(var(--spacing) * 6);
|
padding-bottom: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.text-2xl {
|
.text-2xl {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
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 {
|
.text-sm {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
@@ -1159,6 +1177,9 @@
|
|||||||
--tw-font-weight: var(--font-weight-bold);
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
}
|
}
|
||||||
|
.whitespace-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.opacity-50 {
|
.opacity-50 {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
|
63
assets/js/play.js
Normal file
63
assets/js/play.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
@@ -6,6 +6,7 @@ pub fn empty(content: Markup, name: &str) -> Markup {
|
|||||||
head {
|
head {
|
||||||
script src="/assets/lib/htmx.min.js" { }
|
script src="/assets/lib/htmx.min.js" { }
|
||||||
script defer src="/assets/lib/alpine.min.js" { }
|
script defer src="/assets/lib/alpine.min.js" { }
|
||||||
|
script src="/assets/js/play.js" {}
|
||||||
link rel="stylesheet" href="/assets/css/main.css" { }
|
link rel="stylesheet" href="/assets/css/main.css" { }
|
||||||
title {
|
title {
|
||||||
(name) " - Timo's Workouts"
|
(name) " - Timo's Workouts"
|
||||||
|
@@ -20,7 +20,7 @@ pub struct ExerciseFull {
|
|||||||
pub muscles: Vec<Muscle>,
|
pub muscles: Vec<Muscle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::Type, serde::Deserialize, Debug)]
|
#[derive(sqlx::Type, serde::Deserialize, serde::Serialize, Debug)]
|
||||||
#[sqlx(type_name = "exercise_variant", rename_all = "lowercase")]
|
#[sqlx(type_name = "exercise_variant", rename_all = "lowercase")]
|
||||||
pub enum ExerciseVariant {
|
pub enum ExerciseVariant {
|
||||||
Time,
|
Time,
|
||||||
|
@@ -21,7 +21,7 @@ pub struct WorkoutExercise {
|
|||||||
pub time: Option<i32>,
|
pub time: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow, serde::Serialize)]
|
||||||
pub struct WorkoutExerciseFull {
|
pub struct WorkoutExerciseFull {
|
||||||
pub exercise_id: Uuid,
|
pub exercise_id: Uuid,
|
||||||
pub exercise_type: ExerciseVariant,
|
pub exercise_type: ExerciseVariant,
|
||||||
|
@@ -4,20 +4,19 @@ use crate::{
|
|||||||
util::AppState,
|
util::AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, Router,
|
Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::HeaderMap,
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod play;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(page).delete(delete).put(submit_edit))
|
.route("/", get(page))
|
||||||
.route("/edit", get(edit))
|
.nest("/play", play::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> {
|
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> {
|
||||||
@@ -41,8 +40,6 @@ async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Mar
|
|||||||
&id,
|
&id,
|
||||||
).fetch_all(&state.pool).await?;
|
).fetch_all(&state.pool).await?;
|
||||||
|
|
||||||
tracing::info!("{:?}", exercises);
|
|
||||||
|
|
||||||
let content = html! {
|
let content = html! {
|
||||||
div {
|
div {
|
||||||
div class="mb-5" {
|
div class="mb-5" {
|
||||||
@@ -58,7 +55,7 @@ async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Mar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a href={"/workouts/" (id) "/start"} class="btn btn-success" { "Start Workout" }
|
a href={"/workouts/" (id) "/play"} class="btn btn-success" { "Start Workout" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,15 +69,15 @@ fn display_exercise(exercise: WorkoutExerciseFull) -> Markup {
|
|||||||
h3 { (exercise.name) }
|
h3 { (exercise.name) }
|
||||||
p class="text-sm opacity-50" { (exercise.description) }
|
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 {
|
@match exercise.exercise_type {
|
||||||
ExerciseVariant::Time => {
|
ExerciseVariant::Time => {
|
||||||
div { (exercise.time.unwrap_or(-1))"s" }
|
div { (exercise.time.unwrap_or(-1))"s" }
|
||||||
},
|
},
|
||||||
ExerciseVariant::Number => {
|
ExerciseVariant::Number => {
|
||||||
div {
|
div {
|
||||||
div { (exercise.sets.unwrap_or(-1)) " sets" }
|
div class="whitespace-nowrap text-right" { (exercise.sets.unwrap_or(-1)) " sets" }
|
||||||
div { (exercise.reps.unwrap_or(-1)) " reps" }
|
div class="whitespace-nowrap text-right" { (exercise.reps.unwrap_or(-1)) " reps" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ExerciseVariant::Failure => {
|
ExerciseVariant::Failure => {
|
||||||
@@ -91,83 +88,3 @@ fn display_exercise(exercise: WorkoutExerciseFull) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
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<AppState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
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<AppState>,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Form(form): Form<FormData>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
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! {}))
|
|
||||||
}
|
|
||||||
|
79
src/pages/workouts/id/play.rs
Normal file
79
src/pages/workouts/id/play.rs
Normal file
@@ -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<AppState> {
|
||||||
|
Router::new().route("/", get(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user