320 lines
11 KiB
Rust
320 lines
11 KiB
Rust
use crate::components::multiselect;
|
|
use crate::icons;
|
|
use crate::models::exercises::ExerciseVariant;
|
|
use crate::models::workouts::WorkoutExercise;
|
|
use crate::util::string_into_vec;
|
|
use crate::{AppError, layouts, util::AppState};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::{delete_service, post};
|
|
use axum::{Router, extract::State, http::StatusCode, routing::get};
|
|
use axum_extra::extract::Form;
|
|
use itertools::Itertools;
|
|
use maud::{Markup, html};
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/", get(page).post(submit))
|
|
.route("/exercise_list", post(exercise_list))
|
|
}
|
|
|
|
async fn page(State(state): State<AppState>) -> Result<Markup, AppError> {
|
|
let muscles = sqlx::query_as!(crate::models::Muscle, "SELECT * FROM muscles")
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let categories = sqlx::query_as!(crate::models::Category, "SELECT * FROM categories")
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let content = html! {
|
|
h1 class="mb-5" { "New Workout" }
|
|
|
|
div x-data="{ exercises: [] }" {
|
|
form hx-post="/workouts/new" class="space-y-1" {
|
|
fieldset class="fieldset" {
|
|
legend class="fieldset-legend" { "Name" }
|
|
input required="true" name="name" class="input" {}
|
|
}
|
|
|
|
fieldset class="fieldset" {
|
|
legend class="fieldset-legend" { "Description" }
|
|
textarea required="true" name="description" class="textarea" {}
|
|
}
|
|
|
|
h2 class="mb-3" { "Exercises" }
|
|
|
|
ul class="list space-y-3" {
|
|
template x-for="(value, index) in exercises" {
|
|
li class="bg-base-200 p-3 rounded" {
|
|
div class="mb-3" {
|
|
div class="flex" {
|
|
h3 x-text="value.name" class="" {}
|
|
button type="button" class="btn btn-error btn-sm ml-auto" x-on:click="exercises.splice(index, 1)" { "Delete" }
|
|
}
|
|
div x-text="value.description" class="text-sm opacity-50" {}
|
|
}
|
|
div class="mb-3" {
|
|
select x-model="value.type" class="select select-sm" {
|
|
option disabled selected value="none" { "Type" }
|
|
option value="time" { "Time" }
|
|
option value="number" { "Sets/ Reps" }
|
|
option value="failure" { "Failure" }
|
|
}
|
|
}
|
|
div class="" {
|
|
input type="hidden" name="exercises" x-bind:value="JSON.stringify(value)" {}
|
|
template x-if="value.type === 'time'" {
|
|
div {
|
|
fieldset class="fieldset" {
|
|
legend class="fieldset-legend" { "Time in seconds" }
|
|
input "x-model.number"="value.time" type="number" class="input input-sm" required min="1" title="Must be at least 1 second" {}
|
|
}
|
|
}
|
|
|
|
}
|
|
template x-if="value.type === 'number'" {
|
|
div {
|
|
fieldset class="fieldset" {
|
|
legend class="fieldset-legend" { "Number of sets" }
|
|
input "x-model.number"="value.sets" type="number" class="input input-sm" required min="1" title="Must be at least 1" {}
|
|
}
|
|
fieldset class="fieldset" {
|
|
legend class="fieldset-legend" { "Number of reps per set" }
|
|
input "x-model.number"="value.reps" type="number" class="input input-sm" required min="1" title="Must be at least 1" {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
button type="button" onclick="exercise_modal.showModal()" class="btn" { "Add exercise +" }
|
|
|
|
input type="submit" class="btn" value="save" { }
|
|
}
|
|
|
|
dialog id="exercise_modal" class="modal modal-bottom sm:modal-middle" {
|
|
div class="modal-box" {
|
|
h3 class="mb-3" { "Search Exercises"}
|
|
label class="input mb-2 w-full" {
|
|
div class="h-[1.2em] opacity-50" {
|
|
(icons::search())
|
|
}
|
|
input type="search" name="search" hx-post="/workouts/new/exercise_list" hx-trigger="input changed delay:500ms, keyup[key=='enter'], load" hx-target="#exercise_list" class="grow" placeholder="Search" {}
|
|
}
|
|
div class="flex flex-wrap gap-2 mb-5" {
|
|
select class="select select-sm w-32" {
|
|
option selected { "All Muscles" }
|
|
@for muscle in muscles {
|
|
option value=(muscle.muscle_id) { (muscle.name) }
|
|
}
|
|
}
|
|
select class="select select-sm w-36" {
|
|
option selected { "All Categories" }
|
|
@for category in categories {
|
|
option value=(category.category_id) { (category.name) }
|
|
}
|
|
}
|
|
}
|
|
ul id="exercise_list" {}
|
|
div class="modal-action" {
|
|
form method="dialog" novalidate {
|
|
button class="btn" { "Close" }
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
Ok(layouts::desktop(content, "New Exercise"))
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct FormData {
|
|
name: String,
|
|
description: String,
|
|
exercises: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(tag = "type")]
|
|
enum ExerciseType {
|
|
#[serde(rename = "time")]
|
|
ExerciseTime(ExerciseTime),
|
|
#[serde(rename = "number")]
|
|
ExerciseNumber(ExerciseNumber),
|
|
#[serde(rename = "failure")]
|
|
ExerciseFailure(ExerciseFailure),
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct ExerciseTime {
|
|
id: Uuid,
|
|
time: i32,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct ExerciseNumber {
|
|
id: Uuid,
|
|
sets: i32,
|
|
reps: i32,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
struct ExerciseFailure {
|
|
id: Uuid,
|
|
}
|
|
|
|
fn from_exercise_type_into_workout_exercise(
|
|
exercise_type: ExerciseType,
|
|
workout_id: Uuid,
|
|
position: i32,
|
|
) -> WorkoutExercise {
|
|
match exercise_type {
|
|
ExerciseType::ExerciseTime(time) => WorkoutExercise {
|
|
workout_id,
|
|
exercise_id: time.id,
|
|
exercise_type: ExerciseVariant::Time,
|
|
position,
|
|
sets: None,
|
|
reps: None,
|
|
time: Some(time.time),
|
|
},
|
|
ExerciseType::ExerciseNumber(number) => WorkoutExercise {
|
|
workout_id,
|
|
exercise_id: number.id,
|
|
exercise_type: ExerciseVariant::Number,
|
|
position,
|
|
sets: Some(number.sets),
|
|
reps: Some(number.reps),
|
|
time: None,
|
|
},
|
|
ExerciseType::ExerciseFailure(failure) => WorkoutExercise {
|
|
workout_id,
|
|
exercise_id: failure.id,
|
|
exercise_type: ExerciseVariant::Failure,
|
|
position,
|
|
sets: None,
|
|
reps: None,
|
|
time: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn submit(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<FormData>,
|
|
) -> Result<impl IntoResponse, AppError> {
|
|
let workout_id = Uuid::new_v4();
|
|
|
|
let mut exercises: Vec<ExerciseType> = vec![];
|
|
|
|
// Convert exercises string into releveant objects
|
|
for exercise_str in form.exercises {
|
|
tracing::info!("exercise string: {:?}", exercise_str);
|
|
match serde_json::from_str::<ExerciseType>(&exercise_str) {
|
|
Ok(ex) => exercises.push(ex),
|
|
Err(err) => tracing::error!("Failed to parse exercise: {:?}", err),
|
|
}
|
|
}
|
|
|
|
tracing::info!("{:?}", exercises);
|
|
|
|
let (workout_ids, exercise_ids, exercise_types, positions, sets, reps, times): (
|
|
Vec<_>,
|
|
Vec<_>,
|
|
Vec<_>,
|
|
Vec<_>,
|
|
Vec<_>,
|
|
Vec<_>,
|
|
Vec<_>,
|
|
) = exercises
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, e)| {
|
|
let we = from_exercise_type_into_workout_exercise(e, workout_id, index as i32);
|
|
(
|
|
we.workout_id,
|
|
we.exercise_id,
|
|
we.exercise_type,
|
|
we.position,
|
|
we.sets,
|
|
we.reps,
|
|
we.time,
|
|
)
|
|
})
|
|
.multiunzip();
|
|
|
|
let mut transaction = state.pool.begin().await?;
|
|
|
|
sqlx::query!(
|
|
"INSERT INTO workouts (workout_id, name, description) VALUES ($1, $2, $3)",
|
|
&workout_id,
|
|
&form.name,
|
|
&form.description,
|
|
)
|
|
.execute(&mut *transaction)
|
|
.await?;
|
|
|
|
sqlx::query!(
|
|
"
|
|
INSERT INTO workout_exercises (workout_id, exercise_id, exercise_type, position, sets, reps, time)
|
|
SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::exercise_variant[], $4::int[], $5::int[], $6::int[], $7::int[])
|
|
",
|
|
&workout_ids,
|
|
&exercise_ids,
|
|
&exercise_types as &[ExerciseVariant],
|
|
&positions,
|
|
&sets as &[Option<i32>],
|
|
&reps as &[Option<i32>],
|
|
× as &[Option<i32>],
|
|
).execute(&mut *transaction).await?;
|
|
|
|
transaction.commit().await?;
|
|
|
|
Ok(html! {
|
|
p {
|
|
"New exercise has been created!"
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SearchData {
|
|
search: String,
|
|
}
|
|
|
|
async fn exercise_list(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<SearchData>,
|
|
) -> Result<Markup, AppError> {
|
|
let pattern = format!("%{}%", form.search);
|
|
|
|
let exercises = sqlx::query_as!(
|
|
crate::models::Exercise,
|
|
"SELECT exercise_id, name, description FROM exercises WHERE name ILIKE $1",
|
|
&pattern
|
|
)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
Ok(html! {
|
|
ul class="list" id="exercise_list" {
|
|
@for exercise in exercises {
|
|
li x-on:click={"exercises.push({ id: '"(exercise.exercise_id)"', name: '"(exercise.name)"', description: '"(exercise.description)"', type: 'none' }); exercise_modal.close()"} class="list-row py-2 hover:cursor-pointer" {
|
|
div {
|
|
div class="font-bold" { (exercise.name) }
|
|
div { (exercise.description) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|