From d1029485d489bd322ebe377e9abe5990de7cf9a7 Mon Sep 17 00:00:00 2001 From: Timo Boomers Date: Tue, 15 Jul 2025 13:53:05 +0200 Subject: [PATCH] Added the functionality to insert a workout --- Cargo.lock | 50 +++ Cargo.toml | 5 +- assets/css/main.css | 327 ++++++++++++++---- migrations/20250708192552_add_tables.down.sql | 5 +- migrations/20250708192552_add_tables.sql | 25 +- src/components.rs | 3 + src/components/multiselect.rs | 19 + src/icons.rs | 2 + src/icons/search.rs | 9 + src/layouts/desktop.rs | 19 +- src/main.rs | 1 + src/models.rs | 4 +- src/models/exercises.rs | 30 +- src/models/muscle_groups.rs | 7 - src/models/muscles.rs | 4 +- src/models/workouts.rs | 19 +- src/pages.rs | 2 + src/pages/exercises/new.rs | 74 ++-- src/pages/workouts.rs | 44 +++ src/pages/workouts/new.rs | 319 +++++++++++++++++ src/util.rs | 12 + src/util/error.rs | 2 + tailwind.css | 1 + 23 files changed, 847 insertions(+), 136 deletions(-) create mode 100644 src/components.rs create mode 100644 src/components/multiselect.rs create mode 100644 src/icons/search.rs delete mode 100644 src/models/muscle_groups.rs create mode 100644 src/pages/workouts.rs create mode 100644 src/pages/workouts/new.rs diff --git a/Cargo.lock b/Cargo.lock index b600d94..31ea45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "serde_html_form", + "serde_path_to_error", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -737,6 +762,15 @@ dependencies = [ "libc", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1307,6 +1341,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_json" version = "1.0.140" @@ -2294,9 +2341,12 @@ name = "workout" version = "0.1.0" dependencies = [ "axum", + "axum-extra", "dotenvy", + "itertools", "maud", "serde", + "serde_json", "sqlx", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index 42fadaf..00e1308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = { version = "0.8", feature = [ "form" ] } +axum = { version = "0.8", features = [ "form" ] } +axum-extra = { version = "0.10", features = [ "form" ] } tokio = { version = "1.45", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tls-rustls", "migrate", "uuid"] } maud = { version = "0.27", features = [ "axum" ] } serde = "1.0" +serde_json = "1.0" +itertools = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } diff --git a/assets/css/main.css b/assets/css/main.css index c48b3ce..477b0ec 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -9,12 +9,10 @@ "Courier New", monospace; --spacing: 0.25rem; --container-2xl: 42rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --container-5xl: 64rem; - --container-7xl: 80rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; @@ -172,6 +170,48 @@ } } @layer utilities { + .modal { + pointer-events: none; + visibility: hidden; + position: fixed; + inset: calc(0.25rem * 0); + margin: calc(0.25rem * 0); + display: grid; + height: 100%; + max-height: none; + width: 100%; + max-width: none; + align-items: center; + justify-items: center; + background-color: transparent; + padding: calc(0.25rem * 0); + color: inherit; + overflow-x: hidden; + transition: translate 0.3s ease-out, visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out; + overflow-y: hidden; + overscroll-behavior: contain; + z-index: 999; + &::backdrop { + display: none; + } + &.modal-open, &[open], &:target { + pointer-events: auto; + visibility: visible; + opacity: 100%; + background-color: oklch(0% 0 0/ 0.4); + .modal-box { + translate: 0 0; + scale: 1; + opacity: 1; + } + } + @starting-style { + &.modal-open, &[open], &:target { + visibility: hidden; + opacity: 0%; + } + } + } .menu { display: flex; width: fit-content; @@ -608,6 +648,98 @@ inset-inline-end: 0.75em; } } + .select { + border: var(--border) solid #0000; + position: relative; + display: inline-flex; + flex-shrink: 1; + appearance: none; + align-items: center; + gap: calc(0.25rem * 1.5); + background-color: var(--color-base-100); + padding-inline-start: calc(0.25rem * 4); + padding-inline-end: calc(0.25rem * 7); + vertical-align: middle; + width: clamp(3rem, 20rem, 100%); + height: var(--size); + font-size: 0.875rem; + touch-action: manipulation; + border-start-start-radius: var(--join-ss, var(--radius-field)); + border-start-end-radius: var(--join-se, var(--radius-field)); + border-end-start-radius: var(--join-es, var(--radius-field)); + border-end-end-radius: var(--join-ee, var(--radius-field)); + background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%); + background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%); + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + text-overflow: ellipsis; + box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset; + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset; + } + border-color: var(--input-color); + --input-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000); + } + --size: calc(var(--size-field, 0.25rem) * 10); + [dir="rtl"] & { + background-position: calc(0% + 12px) calc(1px + 50%), calc(0% + 16px) calc(1px + 50%); + } + select { + margin-inline-start: calc(0.25rem * -4); + margin-inline-end: calc(0.25rem * -7); + width: calc(100% + 2.75rem); + appearance: none; + padding-inline-start: calc(0.25rem * 4); + padding-inline-end: calc(0.25rem * 7); + height: calc(100% - 2px); + background: inherit; + border-radius: inherit; + border-style: none; + &:focus, &:focus-within { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + &:not(:last-child) { + margin-inline-end: calc(0.25rem * -5.5); + background-image: none; + } + } + &:focus, &:focus-within { + --input-color: var(--color-base-content); + box-shadow: 0 1px var(--input-color); + @supports (color: color-mix(in lab, red, red)) { + box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000); + } + outline: 2px solid var(--input-color); + outline-offset: 2px; + isolation: isolate; + z-index: 1; + } + &:has(> select[disabled]), &:is(:disabled, [disabled]) { + cursor: not-allowed; + border-color: var(--color-base-200); + background-color: var(--color-base-200); + color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-base-content) 40%, transparent); + } + &::placeholder { + color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-base-content) 20%, transparent); + } + } + } + &:has(> select[disabled]) > select[disabled] { + cursor: not-allowed; + } + } .checkbox { border: var(--border) solid var(--input-color, var(--color-base-content)); @supports (color: color-mix(in lab, red, red)) { @@ -752,6 +884,35 @@ cursor: not-allowed; } } + .modal-box { + grid-column-start: 1; + grid-row-start: 1; + max-height: 100vh; + width: calc(11/12 * 100%); + max-width: 32rem; + background-color: var(--color-base-100); + padding: calc(0.25rem * 6); + transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out; + border-top-left-radius: var(--modal-tl, var(--radius-box)); + border-top-right-radius: var(--modal-tr, var(--radius-box)); + border-bottom-left-radius: var(--modal-bl, var(--radius-box)); + border-bottom-right-radius: var(--modal-br, var(--radius-box)); + scale: 95%; + opacity: 0; + box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px; + overflow-y: auto; + overscroll-behavior: contain; + } + .input-sm { + --size: calc(var(--size-field, 0.25rem) * 8); + font-size: 0.75rem; + &[type="number"] { + &::-webkit-inner-spin-button { + margin-block: calc(0.25rem * -2); + margin-inline-end: calc(0.25rem * -3); + } + } + } .label { display: inline-flex; align-items: center; @@ -789,12 +950,15 @@ } } } + .modal-action { + margin-top: calc(0.25rem * 6); + display: flex; + justify-content: flex-end; + gap: calc(0.25rem * 2); + } .mt-3 { margin-top: calc(var(--spacing) * 3); } - .mt-auto { - margin-top: auto; - } .fieldset-legend { margin-bottom: calc(0.25rem * -1); display: flex; @@ -805,6 +969,9 @@ color: var(--color-base-content); font-weight: 600; } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } @@ -825,6 +992,24 @@ .flex { display: flex; } + .hidden { + display: none; + } + .modal-bottom { + place-items: end; + :where(.modal-box) { + height: auto; + width: 100%; + max-width: none; + max-height: calc(100vh - 5em); + translate: 0 100%; + scale: 1; + --modal-tl: var(--radius-box); + --modal-tr: var(--radius-box); + --modal-bl: 0; + --modal-br: 0; + } + } .btn-square { padding-inline: calc(0.25rem * 0); width: var(--size); @@ -834,12 +1019,21 @@ width: 1.6em; height: 1.6em; } + .h-\[1\.2em\] { + height: 1.2em; + } .h-full { height: 100%; } .h-screen { height: 100vh; } + .w-32 { + width: calc(var(--spacing) * 32); + } + .w-36 { + width: calc(var(--spacing) * 36); + } .w-56 { width: calc(var(--spacing) * 56); } @@ -849,17 +1043,8 @@ .max-w-2xl { max-width: var(--container-2xl); } - .max-w-3xl { - max-width: var(--container-3xl); - } - .max-w-4xl { - max-width: var(--container-4xl); - } - .max-w-5xl { - max-width: var(--container-5xl); - } - .max-w-7xl { - max-width: var(--container-7xl); + .grow { + flex-grow: 1; } .link { cursor: pointer; @@ -877,8 +1062,11 @@ outline-offset: 2px; } } - .justify-center { - justify-content: center; + .flex-wrap { + flex-wrap: wrap; + } + .gap-2 { + gap: calc(var(--spacing) * 2); } .space-y-1 { :where(& > :not(:last-child)) { @@ -887,69 +1075,59 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } - .rounded-box { - border-radius: var(--radius-box); + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } } - .rounded-box { - border-radius: var(--radius-box); + .overflow-y-auto { + overflow-y: auto; } - .border-r { - border-right-style: var(--tw-border-style); - border-right-width: 1px; - } - .border-base-200 { - border-color: var(--color-base-200); - } - .border-base-300 { - border-color: var(--color-base-300); - } - .border-base-content { - border-color: var(--color-base-content); - } - .bg-base-100 { - background-color: var(--color-base-100); + .rounded { + border-radius: 0.25rem; } .bg-base-200 { background-color: var(--color-base-200); } - .bg-base-300 { - background-color: var(--color-base-300); + .p-3 { + padding: calc(var(--spacing) * 3); } - .p-5 { - padding: calc(var(--spacing) * 5); + .py-2 { + padding-block: calc(var(--spacing) * 2); } - .p-10 { - padding: calc(var(--spacing) * 10); - } - .px-3 { - padding-inline: calc(var(--spacing) * 3); - } - .px-10 { - padding-inline: calc(var(--spacing) * 10); - } - .py-3 { - padding-block: calc(var(--spacing) * 3); + .py-10 { + padding-block: calc(var(--spacing) * 10); } .pt-3 { padding-top: calc(var(--spacing) * 3); } - .pt-10 { - padding-top: calc(var(--spacing) * 10); - } .pb-6 { padding-bottom: calc(var(--spacing) * 6); } .pl-10 { padding-left: calc(var(--spacing) * 10); } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } .text-xs { font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } + .select-sm { + --size: calc(var(--size-field, 0.25rem) * 8); + font-size: 0.75rem; + } .font-bold { --tw-font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold); } + .opacity-50 { + opacity: 50%; + } .btn-ghost { &:not(.btn-active, :hover, :active:focus, :focus-visible) { --btn-shadow: ""; @@ -971,10 +1149,39 @@ } } } + .btn-sm { + --fontsize: 0.75rem; + --btn-p: 0.75rem; + --size: calc(var(--size-field, 0.25rem) * 8); + } .btn-error { --btn-color: var(--color-error); --btn-fg: var(--color-error-content); } + .hover\:cursor-pointer { + &:hover { + @media (hover: hover) { + cursor: pointer; + } + } + } + .sm\:modal-middle { + @media (width >= 40rem) { + place-items: center; + :where(.modal-box) { + height: auto; + width: calc(11/12 * 100%); + max-width: 32rem; + max-height: calc(100vh - 5em); + translate: 0 2%; + scale: 98%; + --modal-tl: var(--radius-box); + --modal-tr: var(--radius-box); + --modal-bl: var(--radius-box); + --modal-br: var(--radius-box); + } + } + } } h1 { font-size: var(--text-3xl); @@ -1246,11 +1453,6 @@ h3 { inherits: false; initial-value: 0; } -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-font-weight { syntax: "*"; inherits: false; @@ -1259,7 +1461,6 @@ h3 { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-space-y-reverse: 0; - --tw-border-style: solid; --tw-font-weight: initial; } } diff --git a/migrations/20250708192552_add_tables.down.sql b/migrations/20250708192552_add_tables.down.sql index ab7a81e..5550c6f 100644 --- a/migrations/20250708192552_add_tables.down.sql +++ b/migrations/20250708192552_add_tables.down.sql @@ -1,8 +1,9 @@ -- Add migration script here DROP TABLE exercise_categories; -DROP TABLE exercise_muscle_groups; +DROP TABLE exercise_muscles; DROP TABLE workout_exercises; DROP TABLE categories; -DROP TABLE muscle_groups; +DROP TABLE muscles; DROP TABLE exercises; DROP TABLE workouts; +DROP TYPE exercise_variant; diff --git a/migrations/20250708192552_add_tables.sql b/migrations/20250708192552_add_tables.sql index 9e924bc..e978a56 100644 --- a/migrations/20250708192552_add_tables.sql +++ b/migrations/20250708192552_add_tables.sql @@ -1,4 +1,5 @@ -- Add migration script here +CREATE TYPE exercise_variant AS ENUM ('time', 'number', 'failure'); CREATE TABLE exercises ( exercise_id uuid PRIMARY KEY, @@ -8,8 +9,8 @@ CREATE TABLE exercises ( updated_at TIMESTAMP DEFAULT now() ); -CREATE TABLE muscle_groups ( - muscle_group_id varchar(16) PRIMARY KEY, +CREATE TABLE muscles ( + muscle_id varchar(16) PRIMARY KEY, name varchar NOT NULL ); @@ -27,19 +28,24 @@ CREATE TABLE workouts ( ); CREATE TABLE workout_exercises ( + workout_exercises_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workout_id uuid NOT NULL, exercise_id uuid NOT NULL, - PRIMARY KEY (workout_id, exercise_id), + exercise_type exercise_variant NOT NULL, + position integer NOT NULL, + sets integer, + reps integer, + time integer, FOREIGN KEY (workout_id) REFERENCES workouts(workout_id) ON DELETE CASCADE, FOREIGN KEY (exercise_id) REFERENCES exercises(exercise_id) ON DELETE CASCADE ); -CREATE TABLE exercise_muscle_groups ( +CREATE TABLE exercise_muscles ( exercise_id uuid NOT NULL, - muscle_group_id varchar(16) NOT NULL, - PRIMARY KEY (exercise_id, muscle_group_id), + muscle_id varchar(16) NOT NULL, + PRIMARY KEY (exercise_id, muscle_id), FOREIGN KEY (exercise_id) REFERENCES exercises(exercise_id) ON DELETE CASCADE, - FOREIGN KEY (muscle_group_id) REFERENCES muscle_groups(muscle_group_id) ON DELETE CASCADE + FOREIGN KEY (muscle_id) REFERENCES muscles(muscle_id) ON DELETE CASCADE ); CREATE TABLE exercise_categories ( @@ -52,10 +58,11 @@ CREATE TABLE exercise_categories ( -- Initialize muscle data -INSERT INTO muscle_groups (muscle_group_id, name) VALUES +INSERT INTO muscles (muscle_id, name) VALUES ('chest', 'Chest'), ('back', 'Back'), -('arms', 'Arms'), +('biceps', 'Biceps'), +('triceps', 'Triceps'), ('legs', 'Legs'), ('shoulders', 'Shoulders'); diff --git a/src/components.rs b/src/components.rs new file mode 100644 index 0000000..94d4f99 --- /dev/null +++ b/src/components.rs @@ -0,0 +1,3 @@ +mod multiselect; + +pub use multiselect::multiselect; diff --git a/src/components/multiselect.rs b/src/components/multiselect.rs new file mode 100644 index 0000000..747bae0 --- /dev/null +++ b/src/components/multiselect.rs @@ -0,0 +1,19 @@ +use maud::{Markup, html}; + +pub fn multiselect(items: Vec<(String, String)>, name: &str, label: &str) -> Markup { + html! { + fieldset x-data="{ data: [] }" class="fieldset" { + legend class="fieldset-legend" { (label) } + @for item in items { + @let value = item.0; + @let name = item.1; + + label class="label" { + input type="checkbox" value=(value) x-model="data" class="checkbox" {} + (name) + } + } + input type="hidden" name=(name) x-bind:value="data" {} + } + } +} diff --git a/src/icons.rs b/src/icons.rs index 6b2b275..34f0ceb 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -1,3 +1,5 @@ mod eye; +mod search; pub use eye::eye; +pub use search::search; diff --git a/src/icons/search.rs b/src/icons/search.rs new file mode 100644 index 0000000..f8730e9 --- /dev/null +++ b/src/icons/search.rs @@ -0,0 +1,9 @@ +use maud::{Markup, html}; + +pub fn search() -> Markup { + html! { + svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 256 256" { + path fill="currentColor" d="m229.66 218.34l-50.07-50.06a88.11 88.11 0 1 0-11.31 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" {} + } + } +} diff --git a/src/layouts/desktop.rs b/src/layouts/desktop.rs index 41ca40f..a1e9f16 100644 --- a/src/layouts/desktop.rs +++ b/src/layouts/desktop.rs @@ -4,15 +4,12 @@ use super::empty; pub fn desktop_minimal(content: Markup, name: &str) -> Markup { let content = html! { - div class="w-full h-screen flex justify-center bg-base-200" { - div class="w-full max-w-7xl flex bg-base-100" { - div class="w-56" { - (sidebar()) - } - div class="w-full" { - (content) - } - + div class="w-full h-screen flex" { + div class="w-56" { + (sidebar()) + } + div class="w-full overflow-y-auto" { + (content) } } }; @@ -22,7 +19,7 @@ pub fn desktop_minimal(content: Markup, name: &str) -> Markup { pub fn desktop(content: Markup, name: &str) -> Markup { let content = html! { - div class="pl-10 pt-10 w-full max-w-2xl" { + div class="pl-10 py-10 w-full max-w-2xl" { (content) } }; @@ -32,7 +29,7 @@ pub fn desktop(content: Markup, name: &str) -> Markup { fn sidebar() -> Markup { html! { - ul class="menu w-full h-full border-r border-base-300" { + ul class="menu w-full h-full bg-base-200" { li { div class="pt-3 pb-6 mt-3" { h2 { "Timo's Workouts" } diff --git a/src/main.rs b/src/main.rs index f574b9a..8d71865 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::util::AppState; +mod components; mod database; mod icons; mod layouts; diff --git a/src/models.rs b/src/models.rs index 5dc083c..12129b1 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,9 +1,9 @@ pub mod categories; pub mod exercises; -pub mod muscle_groups; +pub mod muscles; pub mod workouts; pub use categories::Category; pub use exercises::Exercise; -pub use muscle_groups::MuscleGroup; +pub use muscles::Muscle; pub use workouts::Workout; diff --git a/src/models/exercises.rs b/src/models/exercises.rs index 983f05a..77275f3 100644 --- a/src/models/exercises.rs +++ b/src/models/exercises.rs @@ -1,6 +1,9 @@ +use serde::Deserialize; use sqlx::{PgPool, prelude::FromRow}; use uuid::Uuid; +use crate::models::{Category, Muscle}; + #[derive(Debug, FromRow)] pub struct Exercise { pub exercise_id: Uuid, @@ -8,16 +11,19 @@ pub struct Exercise { pub description: String, } -impl Exercise { - pub async fn from_id(id: Uuid, conn: &PgPool) -> Result { - let exercise = sqlx::query_as!( - Exercise, - "SELECT exercise_id, name, description FROM exercises WHERE exercise_id = $1", - id - ) - .fetch_one(conn) - .await?; - - Ok(exercise) - } +#[derive(Debug, FromRow)] +pub struct ExerciseFull { + pub exercise_id: Uuid, + pub name: String, + pub description: String, + pub categories: Vec, + pub muscles: Vec, +} + +#[derive(sqlx::Type, serde::Deserialize, Debug)] +#[sqlx(type_name = "exercise_variant", rename_all = "lowercase")] +pub enum ExerciseVariant { + Time, + Number, + Failure, } diff --git a/src/models/muscle_groups.rs b/src/models/muscle_groups.rs deleted file mode 100644 index 467b885..0000000 --- a/src/models/muscle_groups.rs +++ /dev/null @@ -1,7 +0,0 @@ -use sqlx::{PgPool, prelude::FromRow}; - -#[derive(Debug, FromRow)] -pub struct MuscleGroup { - pub muscle_group_id: String, - pub name: String, -} diff --git a/src/models/muscles.rs b/src/models/muscles.rs index 9532add..c8bec22 100644 --- a/src/models/muscles.rs +++ b/src/models/muscles.rs @@ -1,8 +1,10 @@ +use std::collections::HashSet; + use sqlx::{PgPool, prelude::FromRow}; use uuid::Uuid; #[derive(Debug, FromRow)] pub struct Muscle { - pub muscle_id: i16, + pub muscle_id: String, pub name: String, } diff --git a/src/models/workouts.rs b/src/models/workouts.rs index a18981a..715073b 100644 --- a/src/models/workouts.rs +++ b/src/models/workouts.rs @@ -1,9 +1,22 @@ use sqlx::{PgPool, prelude::FromRow}; use uuid::Uuid; +use crate::models::exercises::ExerciseVariant; + #[derive(Debug, FromRow)] pub struct Workout { - exercise_id: Uuid, - name: String, - description: String, + pub workout_id: Uuid, + pub name: String, + pub description: String, +} + +#[derive(Debug, FromRow)] +pub struct WorkoutExercise { + pub workout_id: Uuid, + pub exercise_id: Uuid, + pub exercise_type: ExerciseVariant, + pub position: i32, + pub sets: Option, + pub reps: Option, + pub time: Option, } diff --git a/src/pages.rs b/src/pages.rs index 4a85563..cda6d6e 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -4,9 +4,11 @@ use crate::util::AppState; mod exercises; mod index; +mod workouts; pub fn routes() -> Router { Router::new() .merge(index::routes()) .nest("/exercises", exercises::routes()) + .nest("/workouts", workouts::routes()) } diff --git a/src/pages/exercises/new.rs b/src/pages/exercises/new.rs index d63779c..9e41330 100644 --- a/src/pages/exercises/new.rs +++ b/src/pages/exercises/new.rs @@ -1,21 +1,32 @@ +use crate::components::multiselect; +use crate::util::string_into_vec; use crate::{AppError, layouts, util::AppState}; use axum::{Form, Router, extract::State, http::StatusCode, routing::get}; use maud::{Markup, html}; use serde::Deserialize; +use uuid::Uuid; pub fn routes() -> Router { Router::new().route("/", get(page).post(submit)) } async fn page(State(state): State) -> Result { - let muscles = sqlx::query_as!(crate::models::MuscleGroup, "SELECT * FROM muscle_groups") + let muscles = sqlx::query_as!(crate::models::Muscle, "SELECT * FROM muscles") .fetch_all(&state.pool) .await?; + let muscles_map: Vec<(String, String)> = + muscles.into_iter().map(|m| (m.muscle_id, m.name)).collect(); + let categories = sqlx::query_as!(crate::models::Category, "SELECT * FROM categories") .fetch_all(&state.pool) .await?; + let categories_map: Vec<(String, String)> = categories + .into_iter() + .map(|c| (c.category_id, c.name)) + .collect(); + let content = html! { h1 class="mb-5" { "New Exercise" } @@ -30,29 +41,9 @@ async fn page(State(state): State) -> Result { textarea required="true" name="description" class="textarea" {} } - fieldset class="fieldset" { - legend class="fieldset-legend" { "Muscle Group" } + (multiselect(muscles_map, "muscles", "Muscles")) - @for muscle in muscles { - @let name = muscle.name; - label class="label" { - input type="checkbox" class="checkbox" {} - (name) - } - } - } - - fieldset class="fieldset" { - legend class="fieldset-legend" { "Category" } - - @for category in categories { - @let name = category.name; - label class="label" { - input type="checkbox" class="checkbox" {} - (name) - } - } - } + (multiselect(categories_map, "categories", "Categories")) input type="submit" class="btn" value="save" { } } @@ -65,13 +56,20 @@ async fn page(State(state): State) -> Result { struct FormData { name: String, description: String, + muscles: String, + categories: String, } async fn submit( State(state): State, Form(form): Form, ) -> Result { - let exercise_id = uuid::Uuid::new_v4(); + let exercise_id = Uuid::new_v4(); + + let muscle_ids = string_into_vec(form.muscles); + let category_ids = string_into_vec(form.categories); + + let mut transaction = state.pool.begin().await?; sqlx::query!( "INSERT INTO exercises (exercise_id, name, description) VALUES ($1, $2, $3)", @@ -79,9 +77,35 @@ async fn submit( form.name, form.description ) - .execute(&state.pool) + .execute(&mut *transaction) .await?; + tracing::info!("{:?} and {:?}", muscle_ids, category_ids); + + // Create and insert relationship between muscles and exercises + if !muscle_ids.is_empty() { + let exercise_ids = vec![exercise_id; muscle_ids.len()]; + + sqlx::query!( + "INSERT INTO exercise_muscles (exercise_id, muscle_id) SELECT * FROM UNNEST($1::uuid[], $2::varchar[])", + &exercise_ids, + &muscle_ids + ).execute(&mut *transaction).await?; + } + + // Create and insert relationship between categories and exercises + if !category_ids.is_empty() { + let exercise_ids = vec![exercise_id; category_ids.len()]; + + sqlx::query!( + "INSERT INTO exercise_categories (exercise_id, category_id) SELECT * FROM UNNEST($1::uuid[], $2::varchar[])", + &exercise_ids, + &category_ids + ).execute(&mut *transaction).await?; + } + + transaction.commit().await?; + Ok(html! { p { "New exercise has been created!" diff --git a/src/pages/workouts.rs b/src/pages/workouts.rs new file mode 100644 index 0000000..f01d54f --- /dev/null +++ b/src/pages/workouts.rs @@ -0,0 +1,44 @@ +use crate::{AppError, icons, layouts, util::AppState}; +use axum::{Router, extract::State, routing::get}; +use maud::{Markup, html}; + +mod new; + +pub fn routes() -> Router { + Router::new() + .route("/", get(page)) + .nest("/new", new::routes()) + // .nest("/{id}", id::routes()) +} + +async fn page(State(state): State) -> Result { + let workouts = sqlx::query_as!( + crate::models::Workout, + "SELECT workout_id, name, description FROM workouts" + ) + .fetch_all(&state.pool) + .await?; + + let content = html! { + h1 { "Workouts" } + a href="/workouts/new" { "new workout +" } + ul class="list" { + @for workout in workouts { + li hx-get={ "/workouts/" (workout.workout_id) } hx-target="body" hx-push-url="true" class="list-row" { + div {} + div { + div class="font-bold" { (workout.name) } + div class="text-xs" { (workout.description) } + } + a class="btn btn-square btn-ghost" { + div class="size-[1.6em]" { + (icons::eye()) + } + } + } + } + } + }; + + Ok(layouts::desktop(content, "Exercises")) +} diff --git a/src/pages/workouts/new.rs b/src/pages/workouts/new.rs new file mode 100644 index 0000000..3ce12d7 --- /dev/null +++ b/src/pages/workouts/new.rs @@ -0,0 +1,319 @@ +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) } + } + } + } + } + }) +} diff --git a/src/util.rs b/src/util.rs index 5255944..3da49d5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use sqlx::PgPool; pub mod error; @@ -6,3 +8,13 @@ pub mod error; pub struct AppState { pub pool: PgPool, } + +pub fn string_into_vec(input: String) -> Vec { + if input.is_empty() { + return vec![]; + } + + let set: HashSet = input.split(",").map(|s| s.trim().to_string()).collect(); + + set.into_iter().collect() +} diff --git a/src/util/error.rs b/src/util/error.rs index 993f359..710284f 100644 --- a/src/util/error.rs +++ b/src/util/error.rs @@ -22,6 +22,8 @@ impl IntoResponse for AppError { impl From for AppError { fn from(value: sqlx::Error) -> Self { + tracing::error!("{:?}", value); + Self::DatabaseError(value) } } diff --git a/tailwind.css b/tailwind.css index 7dd0e90..d5c2af6 100644 --- a/tailwind.css +++ b/tailwind.css @@ -1,6 +1,7 @@ @import "tailwindcss" source(none); @source "./src/pages/"; @source "./src/layouts/"; +@source "./src/components/"; @plugin "daisyui"; @plugin "daisyui/theme" {