Added basic exercise addition and deletion

This commit is contained in:
Timo Boomers 2025-07-09 14:02:18 +02:00
parent 24c784e434
commit 0d1101f84d
Signed by: xeovalyte
SSH Key Fingerprint: SHA256:GWI1hq+MNKR2UOcvk7n9tekASXT8vyazK7vDF9Xyciw
27 changed files with 734 additions and 165 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@
.direnv
.envrc
node_modules
.data
.env

136
Cargo.lock generated
View File

@ -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]]

View File

@ -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"] }

View File

@ -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: "<percentage>";
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: "<percentage>";
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: "<length>";
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;
}
}
}

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

12
docker-compose.yml Normal file
View File

@ -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"

View File

@ -27,6 +27,7 @@
mprocs
rust-analyzer
watchexec
sqlx-cli
] ++ pkgs.lib.optionals pkg.stdenv.isDarwin [
darwin.apple_sdk.frameworks.SystemConfiguration
];

View File

@ -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;

View File

@ -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');

View File

@ -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: "./"

15
src/database.rs Normal file
View File

@ -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")
}

3
src/icons.rs Normal file
View File

@ -0,0 +1,3 @@
mod eye;
pub use eye::eye;

9
src/icons/eye.rs Normal file
View File

@ -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" {}
}
}
}

View File

@ -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")

9
src/models.rs Normal file
View File

@ -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;

8
src/models/categories.rs Normal file
View File

@ -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,
}

23
src/models/exercises.rs Normal file
View File

@ -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<Self, sqlx::Error> {
let exercise = sqlx::query_as!(
Exercise,
"SELECT exercise_id, name, description FROM exercises WHERE exercise_id = $1",
id
)
.fetch_one(conn)
.await?;
Ok(exercise)
}
}

View File

@ -0,0 +1,7 @@
use sqlx::{PgPool, prelude::FromRow};
#[derive(Debug, FromRow)]
pub struct MuscleGroup {
pub muscle_group_id: String,
pub name: String,
}

8
src/models/muscles.rs Normal file
View File

@ -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,
}

9
src/models/workouts.rs Normal file
View File

@ -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,
}

View File

@ -1,9 +1,11 @@
use axum::Router;
use crate::util::AppState;
mod exercises;
mod index;
pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.merge(index::routes())
.nest("/exercises", exercises::routes())

View File

@ -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<AppState> {
Router::new()
.route("/", get(page))
.nest("/new", new::routes())
.nest("/{id}", id::routes())
}
async fn page() -> Markup {
async fn page(State(state): State<AppState>) -> Result<Markup, AppError> {
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"))
}

50
src/pages/exercises/id.rs Normal file
View File

@ -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<AppState> {
Router::new().route("/", get(page).delete(delete))
}
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, 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 { (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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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! {}))
}

View File

@ -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<AppState> {
Router::new().route("/", get(page).post(submit))
}
async fn page() -> Markup {
async fn page(State(state): State<AppState>) -> Result<Markup, AppError> {
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<AppState>,
Form(form): Form<FormData>,
) -> Result<Markup, AppError> {
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!"
}
})
}

View File

@ -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<AppState> {
Router::new().route("/", get(page))
}

8
src/util.rs Normal file
View File

@ -0,0 +1,8 @@
use sqlx::PgPool;
pub mod error;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}

27
src/util/error.rs Normal file
View File

@ -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<sqlx::Error> for AppError {
fn from(value: sqlx::Error) -> Self {
Self::DatabaseError(value)
}
}