diff --git a/.gitignore b/.gitignore index c3fac48..1329a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .direnv .envrc node_modules +.data +.env diff --git a/Cargo.lock b/Cargo.lock index 2ff9b69..b600d94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" @@ -436,7 +442,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -725,6 +743,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -865,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -1078,6 +1106,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1105,7 +1139,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1169,7 +1203,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -1453,6 +1487,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", "webpki-roots 0.26.11", ] @@ -1533,6 +1568,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1570,6 +1606,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1595,6 +1632,7 @@ dependencies = [ "thiserror", "tracing", "url", + "uuid", ] [[package]] @@ -1935,6 +1973,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1959,12 +2009,79 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2163,11 +2280,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "workout" version = "0.1.0" dependencies = [ "axum", + "dotenvy", "maud", "serde", "sqlx", @@ -2176,6 +2303,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5133074..42fadaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] axum = { version = "0.8", feature = [ "form" ] } tokio = { version = "1.45", features = ["full"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tls-rustls", "migrate"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tls-rustls", "migrate", "uuid"] } maud = { version = "0.27", features = [ "axum" ] } serde = "1.0" @@ -15,3 +15,6 @@ tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } tower = { version = "0.5.2", features = ["util"] } tower-http = { version = "0.6.1", features = ["fs", "trace"] } + +dotenvy = "0.15" +uuid = { version = "1.17.0", features = ["v4", "serde"] } diff --git a/assets/css/main.css b/assets/css/main.css index 829de6d..a26f181 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -8,14 +8,16 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --spacing: 0.25rem; - --container-md: 28rem; - --container-xl: 36rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); --text-xl: 1.25rem; --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); + --font-weight-semibold: 600; + --font-weight-bold: 700; --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); } @@ -449,6 +451,59 @@ isolation: isolate; } } + .list { + display: flex; + flex-direction: column; + font-size: 0.875rem; + :where(.list-row) { + --list-grid-cols: minmax(0, auto) 1fr; + position: relative; + display: grid; + grid-auto-flow: column; + gap: calc(0.25rem * 4); + border-radius: var(--radius-box); + padding: calc(0.25rem * 4); + word-break: break-word; + grid-template-columns: var(--list-grid-cols); + &:has(.list-col-grow:nth-child(1)) { + --list-grid-cols: 1fr; + } + &:has(.list-col-grow:nth-child(2)) { + --list-grid-cols: minmax(0, auto) 1fr; + } + &:has(.list-col-grow:nth-child(3)) { + --list-grid-cols: minmax(0, auto) minmax(0, auto) 1fr; + } + &:has(.list-col-grow:nth-child(4)) { + --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr; + } + &:has(.list-col-grow:nth-child(5)) { + --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr; + } + &:has(.list-col-grow:nth-child(6)) { + --list-grid-cols: minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) + minmax(0, auto) 1fr; + } + :not(.list-col-wrap) { + grid-row-start: 1; + } + } + & > :not(:last-child) { + &.list-row, .list-row { + &:after { + content: ""; + border-bottom: var(--border) solid; + inset-inline: var(--radius-box); + position: absolute; + bottom: calc(0.25rem * 0); + border-color: var(--color-base-content); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent); + } + } + } + } + } .input { cursor: text; border: var(--border) solid #0000; @@ -549,98 +604,6 @@ 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)) { @@ -785,6 +748,9 @@ cursor: not-allowed; } } + .list-col-wrap { + grid-row-start: 2; + } .label { display: inline-flex; align-items: center; @@ -846,6 +812,19 @@ .flex { display: flex; } + .btn-square { + padding-inline: calc(0.25rem * 0); + width: var(--size); + height: var(--size); + } + .size-\[1\.2em\] { + width: 1.2em; + height: 1.2em; + } + .size-\[1\.6em\] { + width: 1.6em; + height: 1.6em; + } .h-full { height: 100%; } @@ -858,12 +837,6 @@ .w-full { width: 100%; } - .w-md { - width: var(--container-md); - } - .w-xl { - width: var(--container-xl); - } .link { cursor: pointer; text-decoration-line: underline; @@ -887,42 +860,77 @@ margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); } } - .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))); - } - } - .space-y-5 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse))); - } - } .rounded-box { border-radius: var(--radius-box); } .rounded-box { border-radius: var(--radius-box); } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } - .border-base-300 { - border-color: var(--color-base-300); + .bg-base-100 { + background-color: var(--color-base-100); } .bg-base-200 { background-color: var(--color-base-200); } - .p-4 { - padding: calc(var(--spacing) * 4); + .fill-primary-content { + fill: var(--color-primary-content); } .p-10 { padding: calc(var(--spacing) * 10); } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-5 { + padding-block: calc(var(--spacing) * 5); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .text-primary-content { + color: var(--color-primary-content); + } + .opacity-60 { + opacity: 60%; + } + .shadow-md { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .btn-ghost { + &:not(.btn-active, :hover, :active:focus, :focus-visible) { + --btn-shadow: ""; + --btn-bg: #0000; + --btn-border: #0000; + --btn-noise: none; + &:not(:disabled, [disabled], .btn-disabled) { + outline-color: currentColor; + --btn-fg: currentColor; + } + } + @media (hover: none) { + &:hover:not(.btn-active, :active, :focus-visible, :disabled, [disabled], .btn-disabled) { + --btn-shadow: ""; + --btn-bg: #0000; + --btn-border: #0000; + --btn-noise: none; + --btn-fg: currentColor; + } + } + } + .btn-error { + --btn-color: var(--color-error); + --btn-fg: var(--color-error-content); + } } h1 { font-size: var(--text-3xl); @@ -1194,16 +1202,94 @@ h3 { inherits: false; initial-value: 0; } -@property --tw-border-style { +@property --tw-font-weight { syntax: "*"; inherits: false; - initial-value: solid; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; } @layer properties { @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; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; } } } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3a8149e --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..97ae0a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + db: + image: postgres:17 + volumes: + - ./.data:/var/lib/postgresql/data + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: "password" + POSTGRES_USER: "postgres" + POSTGRES_DB: "postgres" + diff --git a/flake.nix b/flake.nix index 5d3b96c..5b8c6f7 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,7 @@ mprocs rust-analyzer watchexec + sqlx-cli ] ++ pkgs.lib.optionals pkg.stdenv.isDarwin [ darwin.apple_sdk.frameworks.SystemConfiguration ]; diff --git a/migrations/20250708192552_add_tables.down.sql b/migrations/20250708192552_add_tables.down.sql new file mode 100644 index 0000000..ab7a81e --- /dev/null +++ b/migrations/20250708192552_add_tables.down.sql @@ -0,0 +1,8 @@ +-- Add migration script here +DROP TABLE exercise_categories; +DROP TABLE exercise_muscle_groups; +DROP TABLE workout_exercises; +DROP TABLE categories; +DROP TABLE muscle_groups; +DROP TABLE exercises; +DROP TABLE workouts; diff --git a/migrations/20250708192552_add_tables.sql b/migrations/20250708192552_add_tables.sql new file mode 100644 index 0000000..9e924bc --- /dev/null +++ b/migrations/20250708192552_add_tables.sql @@ -0,0 +1,65 @@ +-- Add migration script here + +CREATE TABLE exercises ( + exercise_id uuid PRIMARY KEY, + name varchar NOT NULL, + description varchar NOT NULL, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +CREATE TABLE muscle_groups ( + muscle_group_id varchar(16) PRIMARY KEY, + name varchar NOT NULL +); + +CREATE TABLE categories ( + category_id varchar(16) PRIMARY KEY, + name varchar NOT NULL +); + +CREATE TABLE workouts ( + workout_id uuid PRIMARY KEY, + name varchar NOT NULL, + description varchar NOT NULL, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +CREATE TABLE workout_exercises ( + workout_id uuid NOT NULL, + exercise_id uuid NOT NULL, + PRIMARY KEY (workout_id, exercise_id), + 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 ( + exercise_id uuid NOT NULL, + muscle_group_id varchar(16) NOT NULL, + PRIMARY KEY (exercise_id, muscle_group_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 +); + +CREATE TABLE exercise_categories ( + exercise_id uuid NOT NULL, + category_id varchar(16) NOT NULL, + PRIMARY KEY (exercise_id, category_id), + FOREIGN KEY (exercise_id) REFERENCES exercises(exercise_id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(category_id) ON DELETE CASCADE +); + + +-- Initialize muscle data +INSERT INTO muscle_groups (muscle_group_id, name) VALUES +('chest', 'Chest'), +('back', 'Back'), +('arms', 'Arms'), +('legs', 'Legs'), +('shoulders', 'Shoulders'); + +-- Initialize caregory data +INSERT INTO categories (category_id, name) VALUES +('strength', 'Strength'), +('cardio', 'Cardio'); diff --git a/mprocs.yaml b/mprocs.yaml index ca0bf74..09ad032 100644 --- a/mprocs.yaml +++ b/mprocs.yaml @@ -1,6 +1,6 @@ procs: - # database: - # shell: "docker compose up" + database: + shell: "docker compose up db" backend: shell: "watchexec -w src -r cargo run" cwd: "./" diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..8f8dbea --- /dev/null +++ b/src/database.rs @@ -0,0 +1,15 @@ +use std::time::Duration; + +use sqlx::{PgPool, postgres::PgPoolOptions}; + +pub async fn connect_database() -> PgPool { + let db_connection_str = + std::env::var("DATABASE_URL").expect("Could not find DATABASE_URL environment variable"); + + PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(3)) + .connect(&db_connection_str) + .await + .expect("Could not connect to database") +} diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..6b2b275 --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,3 @@ +mod eye; + +pub use eye::eye; diff --git a/src/icons/eye.rs b/src/icons/eye.rs new file mode 100644 index 0000000..63130f2 --- /dev/null +++ b/src/icons/eye.rs @@ -0,0 +1,9 @@ +use maud::{Markup, html}; + +pub fn eye() -> Markup { + html! { + svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 256 256" { + path fill="currentColor" d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" {} + } + } +} diff --git a/src/main.rs b/src/main.rs index 6f7e349..f574b9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,16 @@ use axum::Router; use tower_http::services::ServeDir; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use crate::util::AppState; + +mod database; +mod icons; mod layouts; +mod models; mod pages; +mod util; + +pub use util::error::AppError; #[tokio::main] async fn main() { @@ -15,10 +23,25 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); + dotenvy::dotenv().expect("Could not initialize dotenvy"); + + // Connect to databse + let pool = database::connect_database().await; + + // Migrate + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Database migrations failed"); + + // Initialize global app state + let app_state = AppState { pool }; + // build our application with a route let app = Router::new() .nest_service("/assets", ServeDir::new("assets")) - .merge(pages::routes()); + .merge(pages::routes()) + .with_state(app_state); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..5dc083c --- /dev/null +++ b/src/models.rs @@ -0,0 +1,9 @@ +pub mod categories; +pub mod exercises; +pub mod muscle_groups; +pub mod workouts; + +pub use categories::Category; +pub use exercises::Exercise; +pub use muscle_groups::MuscleGroup; +pub use workouts::Workout; diff --git a/src/models/categories.rs b/src/models/categories.rs new file mode 100644 index 0000000..33747d6 --- /dev/null +++ b/src/models/categories.rs @@ -0,0 +1,8 @@ +use sqlx::{PgPool, prelude::FromRow}; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub struct Category { + pub category_id: String, + pub name: String, +} diff --git a/src/models/exercises.rs b/src/models/exercises.rs new file mode 100644 index 0000000..983f05a --- /dev/null +++ b/src/models/exercises.rs @@ -0,0 +1,23 @@ +use sqlx::{PgPool, prelude::FromRow}; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub struct Exercise { + pub exercise_id: Uuid, + pub name: String, + 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) + } +} diff --git a/src/models/muscle_groups.rs b/src/models/muscle_groups.rs new file mode 100644 index 0000000..467b885 --- /dev/null +++ b/src/models/muscle_groups.rs @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..9532add --- /dev/null +++ b/src/models/muscles.rs @@ -0,0 +1,8 @@ +use sqlx::{PgPool, prelude::FromRow}; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub struct Muscle { + pub muscle_id: i16, + pub name: String, +} diff --git a/src/models/workouts.rs b/src/models/workouts.rs new file mode 100644 index 0000000..a18981a --- /dev/null +++ b/src/models/workouts.rs @@ -0,0 +1,9 @@ +use sqlx::{PgPool, prelude::FromRow}; +use uuid::Uuid; + +#[derive(Debug, FromRow)] +pub struct Workout { + exercise_id: Uuid, + name: String, + description: String, +} diff --git a/src/pages.rs b/src/pages.rs index f18cf41..4a85563 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,9 +1,11 @@ use axum::Router; +use crate::util::AppState; + mod exercises; mod index; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .merge(index::routes()) .nest("/exercises", exercises::routes()) diff --git a/src/pages/exercises.rs b/src/pages/exercises.rs index cdfcf13..8a784ff 100644 --- a/src/pages/exercises.rs +++ b/src/pages/exercises.rs @@ -1,20 +1,45 @@ -use crate::layouts; -use axum::{Router, routing::get}; +use crate::{AppError, icons, layouts, util::AppState}; +use axum::{Router, extract::State, routing::get}; use maud::{Markup, html}; +mod id; mod new; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/", get(page)) .nest("/new", new::routes()) + .nest("/{id}", id::routes()) } -async fn page() -> Markup { +async fn page(State(state): State) -> Result { + let exercises = sqlx::query_as!( + crate::models::Exercise, + "SELECT exercise_id, name, description FROM exercises" + ) + .fetch_all(&state.pool) + .await?; + let content = html! { h1 { "Exercises" } a href="/exercises/new" { "new exercise +" } + ul class="list" { + @for exercise in exercises { + li hx-get={ "/exercises/" (exercise.exercise_id) } hx-target="body" hx-push-url="true" class="list-row" { + div {} + div { + div class="font-bold" { (exercise.name) } + div class="text-xs" { (exercise.description) } + } + a class="btn btn-square btn-ghost" { + div class="size-[1.6em]" { + (icons::eye()) + } + } + } + } + } }; - layouts::desktop(content, "Exercises") + Ok(layouts::desktop(content, "Exercises")) } diff --git a/src/pages/exercises/id.rs b/src/pages/exercises/id.rs new file mode 100644 index 0000000..833a0b6 --- /dev/null +++ b/src/pages/exercises/id.rs @@ -0,0 +1,50 @@ +use crate::{AppError, layouts, util::AppState}; +use axum::{ + Router, + extract::{Path, State}, + http::HeaderMap, + response::IntoResponse, + routing::get, +}; +use maud::{Markup, html}; +use uuid::Uuid; + +pub fn routes() -> Router { + Router::new().route("/", get(page).delete(delete)) +} + +async fn page(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 { (exercise.name) } + p { (exercise.description) } + button hx-delete={ "/exercises/" (exercise.exercise_id) } hx-confirm="Are you sure that you want to delete this exercise?" class="btn btn-error" { "Delete Exercise" } + }; + + Ok(layouts::desktop(content, "Exercises")) +} + +async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + sqlx::query_as!( + crate::models::Exercise, + "DELETE FROM exercises WHERE exercise_id = $1", + id + ) + .execute(&state.pool) + .await?; + + let mut headers = HeaderMap::new(); + headers.insert("hx-location", "/exercises".parse().unwrap()); + + Ok((headers, html! {})) +} diff --git a/src/pages/exercises/new.rs b/src/pages/exercises/new.rs index ac95fdc..d63779c 100644 --- a/src/pages/exercises/new.rs +++ b/src/pages/exercises/new.rs @@ -1,49 +1,56 @@ -use crate::layouts; -use axum::{Router, routing::get}; +use crate::{AppError, layouts, util::AppState}; +use axum::{Form, Router, extract::State, http::StatusCode, routing::get}; use maud::{Markup, html}; +use serde::Deserialize; -pub fn routes() -> Router { - Router::new().route("/", get(page)) +pub fn routes() -> Router { + Router::new().route("/", get(page).post(submit)) } -async fn page() -> Markup { +async fn page(State(state): State) -> Result { + let muscles = sqlx::query_as!(crate::models::MuscleGroup, "SELECT * FROM muscle_groups") + .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 Exercise" } - forum class="space-y-1" { + form hx-post="/exercises/new" class="space-y-1" { fieldset class="fieldset" { legend class="fieldset-legend" { "Name" } - input class="input" {} + input required="true" name="name" class="input" {} } fieldset class="fieldset" { legend class="fieldset-legend" { "Description" } - textarea class="textarea" {} + textarea required="true" name="description" class="textarea" {} } fieldset class="fieldset" { legend class="fieldset-legend" { "Muscle Group" } - label class="label" { - input type="checkbox" checked="checked" class="checkbox" {} - "Chest" - } - label class="label" { - input type="checkbox" checked="checked" class="checkbox" {} - "Body" + @for muscle in muscles { + @let name = muscle.name; + label class="label" { + input type="checkbox" class="checkbox" {} + (name) + } } } fieldset class="fieldset" { - legend class="fieldset-legend" { "Equipment" } + legend class="fieldset-legend" { "Category" } - label class="label" { - input type="checkbox" checked="checked" class="checkbox" {} - "Weigted plates" - } - label class="label" { - input type="checkbox" checked="checked" class="checkbox" {} - "Jump Rope" + @for category in categories { + @let name = category.name; + label class="label" { + input type="checkbox" class="checkbox" {} + (name) + } } } @@ -51,5 +58,33 @@ async fn page() -> Markup { } }; - layouts::desktop(content, "New Exercise") + Ok(layouts::desktop(content, "New Exercise")) +} + +#[derive(Deserialize, Debug)] +struct FormData { + name: String, + description: String, +} + +async fn submit( + State(state): State, + Form(form): Form, +) -> Result { + let exercise_id = uuid::Uuid::new_v4(); + + sqlx::query!( + "INSERT INTO exercises (exercise_id, name, description) VALUES ($1, $2, $3)", + exercise_id, + form.name, + form.description + ) + .execute(&state.pool) + .await?; + + Ok(html! { + p { + "New exercise has been created!" + } + }) } diff --git a/src/pages/index.rs b/src/pages/index.rs index 459a2a3..d3f3bb1 100644 --- a/src/pages/index.rs +++ b/src/pages/index.rs @@ -1,8 +1,8 @@ -use crate::layouts; +use crate::{layouts, util::AppState}; use axum::{Router, routing::get}; use maud::{Markup, html}; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new().route("/", get(page)) } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..5255944 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,8 @@ +use sqlx::PgPool; + +pub mod error; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, +} diff --git a/src/util/error.rs b/src/util/error.rs new file mode 100644 index 0000000..993f359 --- /dev/null +++ b/src/util/error.rs @@ -0,0 +1,27 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +pub enum AppError { + DatabaseError(sqlx::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self { + AppError::DatabaseError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Error with database".to_owned(), + ), + }; + + (status, message).into_response() + } +} + +impl From for AppError { + fn from(value: sqlx::Error) -> Self { + Self::DatabaseError(value) + } +}