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 { Router::new() .route("/", get(page).post(submit)) .route("/exercise_list", post(exercise_list)) } async fn page(State(state): State) -> Result { 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, } #[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, Form(form): Form, ) -> Result { let workout_id = Uuid::new_v4(); let mut exercises: Vec = vec![]; // Convert exercises string into releveant objects for exercise_str in form.exercises { tracing::info!("exercise string: {:?}", exercise_str); match serde_json::from_str::(&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], &reps as &[Option], × as &[Option], ).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, Form(form): Form, ) -> Result { 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) } } } } } }) }