Added basic exercise addition and deletion

This commit is contained in:
2025-07-09 14:02:18 +02:00
parent 24c784e434
commit 0d1101f84d
27 changed files with 734 additions and 165 deletions

2
.gitignore vendored
View File

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

136
Cargo.lock generated
View File

@@ -146,6 +146,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@@ -436,7 +442,19 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "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]] [[package]]
@@ -725,6 +743,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -865,7 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -1078,6 +1106,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@@ -1105,7 +1139,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.16",
] ]
[[package]] [[package]]
@@ -1169,7 +1203,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.16",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@@ -1453,6 +1487,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tracing", "tracing",
"url", "url",
"uuid",
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
@@ -1533,6 +1568,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror", "thiserror",
"tracing", "tracing",
"uuid",
"whoami", "whoami",
] ]
@@ -1570,6 +1606,7 @@ dependencies = [
"stringprep", "stringprep",
"thiserror", "thiserror",
"tracing", "tracing",
"uuid",
"whoami", "whoami",
] ]
@@ -1595,6 +1632,7 @@ dependencies = [
"thiserror", "thiserror",
"tracing", "tracing",
"url", "url",
"uuid",
] ]
[[package]] [[package]]
@@ -1935,6 +1973,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "wasite" name = "wasite"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -2163,11 +2280,21 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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]] [[package]]
name = "workout" name = "workout"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"dotenvy",
"maud", "maud",
"serde", "serde",
"sqlx", "sqlx",
@@ -2176,6 +2303,7 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
axum = { version = "0.8", feature = [ "form" ] } axum = { version = "0.8", feature = [ "form" ] }
tokio = { version = "1.45", features = ["full"] } 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" ] } maud = { version = "0.27", features = [ "axum" ] }
serde = "1.0" serde = "1.0"
@@ -15,3 +15,6 @@ tracing-subscriber = { version = "0.3", features = [ "env-filter" ] }
tower = { version = "0.5.2", features = ["util"] } tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6.1", features = ["fs", "trace"] } 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", --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace; "Courier New", monospace;
--spacing: 0.25rem; --spacing: 0.25rem;
--container-md: 28rem; --text-xs: 0.75rem;
--container-xl: 36rem; --text-xs--line-height: calc(1 / 0.75);
--text-xl: 1.25rem; --text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25); --text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem; --text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem; --text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-semibold: 600;
--font-weight-bold: 700;
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono); --default-mono-font-family: var(--font-mono);
} }
@@ -449,6 +451,59 @@
isolation: isolate; 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 { .input {
cursor: text; cursor: text;
border: var(--border) solid #0000; border: var(--border) solid #0000;
@@ -549,98 +604,6 @@
inset-inline-end: 0.75em; 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 { .checkbox {
border: var(--border) solid var(--input-color, var(--color-base-content)); border: var(--border) solid var(--input-color, var(--color-base-content));
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -785,6 +748,9 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.list-col-wrap {
grid-row-start: 2;
}
.label { .label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -846,6 +812,19 @@
.flex { .flex {
display: 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 { .h-full {
height: 100%; height: 100%;
} }
@@ -858,12 +837,6 @@
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-md {
width: var(--container-md);
}
.w-xl {
width: var(--container-xl);
}
.link { .link {
cursor: pointer; cursor: pointer;
text-decoration-line: underline; text-decoration-line: underline;
@@ -887,42 +860,77 @@
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); 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 { .rounded-box {
border-radius: var(--radius-box); border-radius: var(--radius-box);
} }
.rounded-box { .rounded-box {
border-radius: var(--radius-box); border-radius: var(--radius-box);
} }
.border { .bg-base-100 {
border-style: var(--tw-border-style); background-color: var(--color-base-100);
border-width: 1px;
}
.border-base-300 {
border-color: var(--color-base-300);
} }
.bg-base-200 { .bg-base-200 {
background-color: var(--color-base-200); background-color: var(--color-base-200);
} }
.p-4 { .fill-primary-content {
padding: calc(var(--spacing) * 4); fill: var(--color-primary-content);
} }
.p-10 { .p-10 {
padding: calc(var(--spacing) * 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 { h1 {
font-size: var(--text-3xl); font-size: var(--text-3xl);
@@ -1194,16 +1202,94 @@ h3 {
inherits: false; inherits: false;
initial-value: 0; initial-value: 0;
} }
@property --tw-border-style { @property --tw-font-weight {
syntax: "*"; syntax: "*";
inherits: false; 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 { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @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 { *, ::before, ::after, ::backdrop {
--tw-space-y-reverse: 0; --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 mprocs
rust-analyzer rust-analyzer
watchexec watchexec
sqlx-cli
] ++ pkgs.lib.optionals pkg.stdenv.isDarwin [ ] ++ pkgs.lib.optionals pkg.stdenv.isDarwin [
darwin.apple_sdk.frameworks.SystemConfiguration 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: procs:
# database: database:
# shell: "docker compose up" shell: "docker compose up db"
backend: backend:
shell: "watchexec -w src -r cargo run" shell: "watchexec -w src -r cargo run"
cwd: "./" 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 tower_http::services::ServeDir;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::util::AppState;
mod database;
mod icons;
mod layouts; mod layouts;
mod models;
mod pages; mod pages;
mod util;
pub use util::error::AppError;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -15,10 +23,25 @@ async fn main() {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .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 // build our application with a route
let app = Router::new() let app = Router::new()
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))
.merge(pages::routes()); .merge(pages::routes())
.with_state(app_state);
// run it // run it
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") 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 axum::Router;
use crate::util::AppState;
mod exercises; mod exercises;
mod index; mod index;
pub fn routes() -> Router { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.merge(index::routes()) .merge(index::routes())
.nest("/exercises", exercises::routes()) .nest("/exercises", exercises::routes())

View File

@@ -1,20 +1,45 @@
use crate::layouts; use crate::{AppError, icons, layouts, util::AppState};
use axum::{Router, routing::get}; use axum::{Router, extract::State, routing::get};
use maud::{Markup, html}; use maud::{Markup, html};
mod id;
mod new; mod new;
pub fn routes() -> Router { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(page)) .route("/", get(page))
.nest("/new", new::routes()) .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! { let content = html! {
h1 { "Exercises" } h1 { "Exercises" }
a href="/exercises/new" { "new exercise +" } 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 crate::{AppError, layouts, util::AppState};
use axum::{Router, routing::get}; use axum::{Form, Router, extract::State, http::StatusCode, routing::get};
use maud::{Markup, html}; use maud::{Markup, html};
use serde::Deserialize;
pub fn routes() -> Router { pub fn routes() -> Router<AppState> {
Router::new().route("/", get(page)) 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! { let content = html! {
h1 class="mb-5" { "New Exercise" } h1 class="mb-5" { "New Exercise" }
forum class="space-y-1" { form hx-post="/exercises/new" class="space-y-1" {
fieldset class="fieldset" { fieldset class="fieldset" {
legend class="fieldset-legend" { "Name" } legend class="fieldset-legend" { "Name" }
input class="input" {} input required="true" name="name" class="input" {}
} }
fieldset class="fieldset" { fieldset class="fieldset" {
legend class="fieldset-legend" { "Description" } legend class="fieldset-legend" { "Description" }
textarea class="textarea" {} textarea required="true" name="description" class="textarea" {}
} }
fieldset class="fieldset" { fieldset class="fieldset" {
legend class="fieldset-legend" { "Muscle Group" } legend class="fieldset-legend" { "Muscle Group" }
label class="label" { @for muscle in muscles {
input type="checkbox" checked="checked" class="checkbox" {} @let name = muscle.name;
"Chest" label class="label" {
} input type="checkbox" class="checkbox" {}
label class="label" { (name)
input type="checkbox" checked="checked" class="checkbox" {} }
"Body"
} }
} }
fieldset class="fieldset" { fieldset class="fieldset" {
legend class="fieldset-legend" { "Equipment" } legend class="fieldset-legend" { "Category" }
label class="label" { @for category in categories {
input type="checkbox" checked="checked" class="checkbox" {} @let name = category.name;
"Weigted plates" label class="label" {
} input type="checkbox" class="checkbox" {}
label class="label" { (name)
input type="checkbox" checked="checked" class="checkbox" {} }
"Jump Rope"
} }
} }
@@ -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 axum::{Router, routing::get};
use maud::{Markup, html}; use maud::{Markup, html};
pub fn routes() -> Router { pub fn routes() -> Router<AppState> {
Router::new().route("/", get(page)) 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)
}
}