Compare commits

..

19 Commits

Author SHA1 Message Date
f82c31392d Added mobile navigation dock
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m27s
2025-07-18 13:43:31 +02:00
8cdb335fa1 added copying of static assets
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m26s
2025-07-18 12:20:56 +02:00
6c04f21a20 changed listen address
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m24s
2025-07-18 12:14:31 +02:00
31df9a24aa changed how dotenvy works
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m28s
2025-07-18 11:46:29 +02:00
7f200edd72 changed image again
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m43s
2025-07-18 11:40:28 +02:00
bbd51275c8 changed image to buster
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 1m37s
2025-07-18 11:37:18 +02:00
5ba6b1bf16 added offline mode
All checks were successful
Cargo Build & Test / Push to container registry (push) Successful in 2m45s
2025-07-18 11:27:07 +02:00
226e7114d4 changed rust version
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 1m2s
2025-07-18 11:17:25 +02:00
7ef71125cc commented extra dependencies line
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 22s
2025-07-18 11:13:54 +02:00
7638592c03 added Dockerfile
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 15s
2025-07-18 11:12:21 +02:00
e60f11e9a8 changed runner container
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 1m39s
2025-07-18 10:59:25 +02:00
d94b67e689 simplified metadata
Some checks failed
Cargo Build & Test / Push to container registry (push) Has been cancelled
2025-07-18 10:43:03 +02:00
52673a9aa5 changed tag
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 4s
2025-07-18 10:41:08 +02:00
779a62d587 added container image
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 31s
2025-07-18 10:38:58 +02:00
5887e083d9 changed container
Some checks failed
Cargo Build & Test / Push to container registry (push) Has been cancelled
2025-07-18 10:33:49 +02:00
91c4026baa removed build and test
Some checks failed
Cargo Build & Test / Push to container registry (push) Failing after 3s
2025-07-18 10:30:46 +02:00
87fcf6d1dd changed rust versions
Some checks failed
Cargo Build & Test / Push to container registry (push) Has been cancelled
Cargo Build & Test / Timo's Workouts - latest (push) Has been cancelled
2025-07-18 10:27:28 +02:00
3db46d0130 Added actions
Some checks failed
Cargo Build & Test / Timo's Workouts - latest (beta) (push) Failing after 23s
Cargo Build & Test / Timo's Workouts - latest (nightly) (push) Failing after 3s
Cargo Build & Test / Timo's Workouts - latest (stable) (push) Failing after 3s
Cargo Build & Test / Push to container registry (push) Failing after 2s
2025-07-18 10:24:28 +02:00
ad98db7f7c Very basic workout player 2025-07-18 10:06:24 +02:00
27 changed files with 812 additions and 116 deletions

37
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,37 @@
name: Cargo Build & Test
on:
push:
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
push:
name: Push to container registry
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:rust-latest
steps:
- uses: actions/checkout@v4
- run: rustup update stable && rustup default stable
- run: cargo install sqlx-cli && cargo sqlx prepare --check
- name: Login in to the container registry
uses: docker/login-action@v3
with:
registry: gitea.xeovalyte.dev
username: xeovalyte
password: ${{ secrets.RUNNER_TOKEN }}
- name: Build an publish
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: gitea.xeovalyte.dev/xeovalyte/workout:latest

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM categories",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "category_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "002d293c501005c025256289cb7ef4f89f767c53ad62a544b68cb7e8f6750c96"
}

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT exercise_id, name, description FROM exercises WHERE name ILIKE $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exercise_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "421d1864f2062f99355f474195c36a05dae84949eb65fed12eb615859d774bda"
}

View File

@@ -0,0 +1,75 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT exercises.exercise_id, exercise_type as \"exercise_type:ExerciseVariant\", sets, reps, time, name, description, position\n FROM workout_exercises\n JOIN exercises ON workout_exercises.exercise_id = exercises.exercise_id\n WHERE workout_id = $1\n ORDER BY position\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exercise_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "exercise_type:ExerciseVariant",
"type_info": {
"Custom": {
"name": "exercise_variant",
"kind": {
"Enum": [
"time",
"number",
"failure"
]
}
}
}
},
{
"ordinal": 2,
"name": "sets",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "reps",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "time",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "description",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "position",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
true,
true,
false,
false,
false
]
},
"hash": "4f4994330ab5b680ddca03e962ce2a0a15bfbc27f28e30788422c5aa20e6c965"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM exercises WHERE exercise_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "65160034413e2aed60354f87649e8d707b2f28c8d378d8fd47b0a33222a4396b"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO exercise_categories (exercise_id, category_id) SELECT * FROM UNNEST($1::uuid[], $2::varchar[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"VarcharArray"
]
},
"nullable": []
},
"hash": "73476d5fa346379f89b0deef86cd60615564d3ca4c2e505c20f775d7433bc643"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE exercises SET name = $1, description = $2 WHERE exercise_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Uuid"
]
},
"nullable": []
},
"hash": "746236a3e38a5e49d4d2b65d7b26ffbc1092cebaf07a0840aa4350d94c0c5585"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM muscles",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "muscle_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "99dd58daa888b66b5e75f3603583d82e6aa665668dd45e2e40d7ccc4846c5ef5"
}

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workout_id, name, description FROM workouts WHERE workout_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workout_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "9f989d04f5f8aa4b916987ba1b62fb3d1996dc025aba53b4b28997e6f49db61e"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO exercises (exercise_id, name, description) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "b10aa422afe7a01589a6f090b0a2cab616fd28c793c91f2046864e7575b0d242"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT exercise_id, name, description FROM exercises",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exercise_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "b8d583d3a2ca1b196acc9a27cb7d8f1746d7b2c0063e8770a068ec2444ca8b8b"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO workout_exercises (workout_id, exercise_id, exercise_type, position, sets, reps, time)\n SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::exercise_variant[], $4::int[], $5::int[], $6::int[], $7::int[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
{
"Custom": {
"name": "exercise_variant[]",
"kind": {
"Array": {
"Custom": {
"name": "exercise_variant",
"kind": {
"Enum": [
"time",
"number",
"failure"
]
}
}
}
}
}
},
"Int4Array",
"Int4Array",
"Int4Array",
"Int4Array"
]
},
"nullable": []
},
"hash": "cd1cd6869f9be3b1bc95c8b273451e29f26fd153bb6041a5040e2eaa285d51c4"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workouts (workout_id, name, description) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "cefe6cff888e999bbe55688bcf1c8c1657013fef519791de1274ab83fd8377a9"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workout_id, name, description FROM workouts",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workout_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "d7c9cb014b26cd70eaf742fe04d79e71271b311ad1418fa8eea59263431e22ab"
}

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT exercise_id, name, description FROM exercises WHERE exercise_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exercise_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "dbf66e8b66a946d0327dd71562d540f24245b576798814c366770800bc8a1616"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO exercise_muscles (exercise_id, muscle_id) SELECT * FROM UNNEST($1::uuid[], $2::varchar[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"VarcharArray"
]
},
"nullable": []
},
"hash": "dfb1728ec2f194bc898deab2bbddcd9cfa11edb60a6d1625534eb6388bcf948b"
}

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM rust:1.88-bullseye as builder
WORKDIR /usr/src/myapp
COPY . .
RUN cargo install --path .
FROM debian:bullseye-slim
# RUN apt-get update && apt-get install -y extra-runtime-dependencies && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/workout /usr/local/bin/workout
COPY assets /app/assets
WORKDIR /app
CMD ["workout"]

View File

@@ -19,6 +19,10 @@
--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-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--text-6xl: 3.75rem;
--text-6xl--line-height: 1;
--font-weight-bold: 700; --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);
@@ -371,6 +375,74 @@
} }
} }
} }
.dock {
position: fixed;
right: calc(0.25rem * 0);
bottom: calc(0.25rem * 0);
left: calc(0.25rem * 0);
z-index: 1;
display: flex;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-around;
background-color: var(--color-base-100);
padding: calc(0.25rem * 2);
color: currentColor;
border-top: 0.5px solid var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-top: 0.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);
}
height: 4rem;
height: calc(4rem + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
> * {
position: relative;
margin-bottom: calc(0.25rem * 2);
display: flex;
height: 100%;
max-width: calc(0.25rem * 32);
flex-shrink: 1;
flex-basis: 100%;
cursor: pointer;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
border-radius: var(--radius-box);
background-color: transparent;
transition: opacity 0.2s ease-out;
@media (hover: hover) {
&:hover {
opacity: 80%;
}
}
&[aria-disabled="true"], &[disabled] {
&, &:hover {
pointer-events: none;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
}
opacity: 100%;
}
}
.dock-label {
font-size: 0.6875rem;
}
&:after {
content: "";
position: absolute;
height: calc(0.25rem * 1);
width: calc(0.25rem * 6);
border-radius: calc(infinity * 1px);
background-color: transparent;
bottom: 0.2rem;
border-top: 3px solid transparent;
transition: background-color 0.1s ease-out, text-color 0.1s ease-out, width 0.1s ease-out;
}
}
}
.btn { .btn {
:where(&) { :where(&) {
width: unset; width: unset;
@@ -956,18 +1028,18 @@
justify-content: flex-end; justify-content: flex-end;
gap: calc(0.25rem * 2); gap: calc(0.25rem * 2);
} }
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mt-3 { .mt-3 {
margin-top: calc(var(--spacing) * 3); margin-top: calc(var(--spacing) * 3);
} }
.mt-5 {
margin-top: calc(var(--spacing) * 5);
}
.mt-auto {
margin-top: auto;
}
.mr-1 { .mr-1 {
margin-right: calc(var(--spacing) * 1); margin-right: calc(var(--spacing) * 1);
} }
.mr-3 {
margin-right: calc(var(--spacing) * 3);
}
.fieldset-legend { .fieldset-legend {
margin-bottom: calc(0.25rem * -1); margin-bottom: calc(0.25rem * -1);
display: flex; display: flex;
@@ -1024,16 +1096,24 @@
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
} }
.size-5 {
width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5);
}
.size-\[1\.5em\] {
width: 1.5em;
height: 1.5em;
}
.size-\[1\.6em\] { .size-\[1\.6em\] {
width: 1.6em; width: 1.6em;
height: 1.6em; height: 1.6em;
} }
.h-48 {
height: calc(var(--spacing) * 48);
}
.h-\[1\.2em\] { .h-\[1\.2em\] {
height: 1.2em; height: 1.2em;
} }
.h-\[92px\] {
height: 92px;
}
.h-full { .h-full {
height: 100%; height: 100%;
} }
@@ -1049,15 +1129,9 @@
.w-56 { .w-56 {
width: calc(var(--spacing) * 56); width: calc(var(--spacing) * 56);
} }
.w-\[1\.5em\] {
width: 1.5em;
}
.w-\[1\.6em\] { .w-\[1\.6em\] {
width: 1.6em; width: 1.6em;
} }
.w-\[1\.7em\] {
width: 1.7em;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@@ -1098,6 +1172,9 @@
.gap-2 { .gap-2 {
gap: calc(var(--spacing) * 2); gap: calc(var(--spacing) * 2);
} }
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.space-y-1 { .space-y-1 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@@ -1139,10 +1216,24 @@
.pb-6 { .pb-6 {
padding-bottom: calc(var(--spacing) * 6); padding-bottom: calc(var(--spacing) * 6);
} }
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-2xl { .text-2xl {
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
} }
.text-4xl {
font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
.text-6xl {
font-size: var(--text-6xl);
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
.text-sm { .text-sm {
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height)); line-height: var(--tw-leading, var(--text-sm--line-height));
@@ -1159,6 +1250,9 @@
--tw-font-weight: var(--font-weight-bold); --tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
} }
.whitespace-nowrap {
white-space: nowrap;
}
.opacity-50 { .opacity-50 {
opacity: 50%; opacity: 50%;
} }
@@ -1203,6 +1297,11 @@
} }
} }
} }
.max-md\:hidden {
@media (width < 48rem) {
display: none;
}
}
.sm\:modal-middle { .sm\:modal-middle {
@media (width >= 40rem) { @media (width >= 40rem) {
place-items: center; place-items: center;
@@ -1220,6 +1319,11 @@
} }
} }
} }
.md\:hidden {
@media (width >= 48rem) {
display: none;
}
}
} }
h2 { h2 {
font-size: var(--text-xl); font-size: var(--text-xl);

63
assets/js/play.js Normal file
View File

@@ -0,0 +1,63 @@
document.addEventListener('alpine:init', () => {
Alpine.data('exercisePlayer', () => {
return {
timer: null,
counter: 0,
timeLeft: 0,
currentPosition: 0,
exercises: [],
isPaused: false,
init() {
this.exercises = window.exerciseData;
},
start() {
if (!this.isPaused) {
this.timeLeft = this.exercises[this.currentPosition].time;
this.counter = 0;
}
this.timer = setInterval(() => {
if (this.timeLeft === 0) {
this.next()
};
this.counter++;
this.timeLeft--;
}, 1000);
},
destroy() {
// Detach the handler, avoiding memory and side-effect leakage
if (this.timer) {
clearInterval(this.timer);
}
if (!this.isPaused) {
this.counter = 0;
this.timeLeft = 0;
}
},
next() {
this.destroy()
this.currentPosition++;
if (this.exercises[this.currentPosition].exercise_type === "Time") {
this.start()
}
},
previous() {
this.destroy();
this.currentPosition--;
},
pause() {
if (this.isPaused) {
this.start();
this.isPaused = false;
} else {
this.isPaused = true;
this.destroy()
}
}
}
})
})

View File

@@ -12,3 +12,36 @@ pub fn arrow_left() -> Markup {
} }
} }
} }
pub fn house() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 256 256" {
path
fill="currentColor"
d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"
{}
}
}
}
pub fn barbell() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 256 256" {
path
fill="currentColor"
d="M248 120h-8V88a16 16 0 0 0-16-16h-16v-8a16 16 0 0 0-16-16h-24a16 16 0 0 0-16 16v56h-48V64a16 16 0 0 0-16-16H64a16 16 0 0 0-16 16v8H32a16 16 0 0 0-16 16v32H8a8 8 0 0 0 0 16h8v32a16 16 0 0 0 16 16h16v8a16 16 0 0 0 16 16h24a16 16 0 0 0 16-16v-56h48v56a16 16 0 0 0 16 16h24a16 16 0 0 0 16-16v-8h16a16 16 0 0 0 16-16v-32h8a8 8 0 0 0 0-16M32 168V88h16v80Zm56 24H64V64h24zm104 0h-24V64h24zm32-24h-16V88h16Z"
{}
}
}
}
pub fn person_simple_run() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 256 256" {
path
fill="currentColor"
d="M152 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32m0-48a16 16 0 1 1-16 16a16 16 0 0 1 16-16m67.31 100.68c-.61.28-7.49 3.28-19.67 3.28c-13.85 0-34.55-3.88-60.69-20a169.3 169.3 0 0 1-15.41 32.34a104.3 104.3 0 0 1 31.31 15.81C173.92 186.65 184 207.35 184 232a8 8 0 0 1-16 0c0-41.7-34.69-56.71-54.14-61.85c-.55.7-1.12 1.41-1.69 2.1c-19.64 23.8-44.25 36.18-71.63 36.18a92 92 0 0 1-9.34-.43a8 8 0 0 1 1.6-16c25.92 2.58 48.47-7.49 67-30c12.49-15.14 21-33.61 25.25-47c-38.92-22.65-63.78-3.37-64.05-3.16a8 8 0 1 1-10-12.48c1.5-1.2 37.22-29 89.51 6.57c45.47 30.91 71.93 20.31 72.18 20.19a8 8 0 1 1 6.63 14.56Z"
{}
}
}
}

View File

@@ -7,11 +7,16 @@ use super::empty;
pub fn desktop_minimal(content: Markup, name: &str) -> Markup { pub fn desktop_minimal(content: Markup, name: &str) -> Markup {
let content = html! { let content = html! {
div class="w-full h-screen flex" { div class="w-full h-screen flex" {
div class="w-56" { div class="max-md:hidden w-56" {
(sidebar()) (sidebar())
} }
div class="w-full h-full overflow-y-auto" { div class="w-full h-full flex flex-col" {
(content) div class="overflow-y-auto" {
(content)
}
div class="md:hidden mt-auto" {
(dock())
}
} }
} }
}; };
@@ -40,12 +45,15 @@ fn sidebar() -> Markup {
h2 { "Timo's Workouts" } h2 { "Timo's Workouts" }
} }
a href="/" { a href="/" {
div class="size-5" { (icons::house()) }
"Overview" "Overview"
} }
a href="/workouts" { a href="/workouts" {
div class="size-5" { (icons::barbell()) }
"Workouts" "Workouts"
} }
a href="/exercises" { a href="/exercises" {
div class="size-5" { (icons::person_simple_run()) }
"Exercises" "Exercises"
} }
} }
@@ -53,6 +61,25 @@ fn sidebar() -> Markup {
} }
} }
fn dock() -> Markup {
html! {
div class="dock" {
a href="/" {
div class="size-[1.5em]" { (icons::house()) }
span class="dock-label" { "Overview" }
}
a href="/workouts" {
div class="size-[1.5em]" { (icons::barbell()) }
span class="dock-label" { "Workouts" }
}
a href="/exercises" {
div class="size-[1.5em]" { (icons::person_simple_run()) }
span class="dock-label" { "Exercises" }
}
}
}
}
fn header(title: &str, back_button: bool) -> Markup { fn header(title: &str, back_button: bool) -> Markup {
html! { html! {
div class="flex items-center mb-5" { div class="flex items-center mb-5" {

View File

@@ -6,6 +6,7 @@ pub fn empty(content: Markup, name: &str) -> Markup {
head { head {
script src="/assets/lib/htmx.min.js" { } script src="/assets/lib/htmx.min.js" { }
script defer src="/assets/lib/alpine.min.js" { } script defer src="/assets/lib/alpine.min.js" { }
script src="/assets/js/play.js" {}
link rel="stylesheet" href="/assets/css/main.css" { } link rel="stylesheet" href="/assets/css/main.css" { }
title { title {
(name) " - Timo's Workouts" (name) " - Timo's Workouts"

View File

@@ -24,7 +24,7 @@ async fn main() {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
dotenvy::dotenv().expect("Could not initialize dotenvy"); let _ = dotenvy::dotenv();
// Connect to databse // Connect to databse
let pool = database::connect_database().await; let pool = database::connect_database().await;
@@ -45,9 +45,7 @@ async fn main() {
.with_state(app_state); .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("0.0.0.0:3000").await.unwrap();
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap()); println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }

View File

@@ -20,7 +20,7 @@ pub struct ExerciseFull {
pub muscles: Vec<Muscle>, pub muscles: Vec<Muscle>,
} }
#[derive(sqlx::Type, serde::Deserialize, Debug)] #[derive(sqlx::Type, serde::Deserialize, serde::Serialize, Debug)]
#[sqlx(type_name = "exercise_variant", rename_all = "lowercase")] #[sqlx(type_name = "exercise_variant", rename_all = "lowercase")]
pub enum ExerciseVariant { pub enum ExerciseVariant {
Time, Time,

View File

@@ -21,7 +21,7 @@ pub struct WorkoutExercise {
pub time: Option<i32>, pub time: Option<i32>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow, serde::Serialize)]
pub struct WorkoutExerciseFull { pub struct WorkoutExerciseFull {
pub exercise_id: Uuid, pub exercise_id: Uuid,
pub exercise_type: ExerciseVariant, pub exercise_type: ExerciseVariant,

View File

@@ -4,20 +4,19 @@ use crate::{
util::AppState, util::AppState,
}; };
use axum::{ use axum::{
Form, Router, Router,
extract::{Path, State}, extract::{Path, State},
http::HeaderMap,
response::IntoResponse,
routing::get, routing::get,
}; };
use maud::{Markup, html}; use maud::{Markup, html};
use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
mod play;
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(page).delete(delete).put(submit_edit)) .route("/", get(page))
.route("/edit", get(edit)) .nest("/play", play::routes())
} }
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> { async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> {
@@ -41,8 +40,6 @@ async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Mar
&id, &id,
).fetch_all(&state.pool).await?; ).fetch_all(&state.pool).await?;
tracing::info!("{:?}", exercises);
let content = html! { let content = html! {
div { div {
div class="mb-5" { div class="mb-5" {
@@ -58,7 +55,7 @@ async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Mar
} }
} }
a href={"/workouts/" (id) "/start"} class="btn btn-success" { "Start Workout" } a href={"/workouts/" (id) "/play"} class="btn btn-success" { "Start Workout" }
} }
}; };
@@ -72,15 +69,15 @@ fn display_exercise(exercise: WorkoutExerciseFull) -> Markup {
h3 { (exercise.name) } h3 { (exercise.name) }
p class="text-sm opacity-50" { (exercise.description) } p class="text-sm opacity-50" { (exercise.description) }
} }
div class="ml-auto flex items-center" { div class="ml-auto flex gap-2 items-center" {
@match exercise.exercise_type { @match exercise.exercise_type {
ExerciseVariant::Time => { ExerciseVariant::Time => {
div { (exercise.time.unwrap_or(-1))"s" } div { (exercise.time.unwrap_or(-1))"s" }
}, },
ExerciseVariant::Number => { ExerciseVariant::Number => {
div { div {
div { (exercise.sets.unwrap_or(-1)) " sets" } div class="whitespace-nowrap text-right" { (exercise.sets.unwrap_or(-1)) " sets" }
div { (exercise.reps.unwrap_or(-1)) " reps" } div class="whitespace-nowrap text-right" { (exercise.reps.unwrap_or(-1)) " reps" }
} }
}, },
ExerciseVariant::Failure => { ExerciseVariant::Failure => {
@@ -91,83 +88,3 @@ fn display_exercise(exercise: WorkoutExerciseFull) -> Markup {
} }
} }
} }
async fn delete(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
sqlx::query_as!(
crate::models::Exercise,
"DELETE FROM workouts WHERE workout_id = $1",
id
)
.execute(&state.pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert("hx-location", "/workouts".parse().unwrap());
Ok((headers, html! {}))
}
async fn edit(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, 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 class="mb-5" { "Edit Exercise" }
form hx-put={"/exercises/" (exercise.exercise_id)} class="space-y-1" {
fieldset class="fieldset" {
legend class="fieldset-legend" { "Name" }
input required="true" name="name" class="input" value={(exercise.name)} {}
}
fieldset class="fieldset" {
legend class="fieldset-legend" { "Description" }
textarea required="true" name="description" class="textarea" { (exercise.description) }
}
input type="submit" class="btn" value="save" { }
}
};
Ok(layouts::desktop(content, "Edit Exercises", true))
}
#[derive(Deserialize, Debug)]
struct FormData {
name: String,
description: String,
}
async fn submit_edit(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Form(form): Form<FormData>,
) -> Result<impl IntoResponse, AppError> {
sqlx::query_as!(
crate::models::Exercise,
"UPDATE exercises SET name = $1, description = $2 WHERE exercise_id = $3",
form.name,
form.description,
id
)
.execute(&state.pool)
.await?;
let mut headers = HeaderMap::new();
let location = format!("/exercises/{}", id);
headers.insert("hx-location", location.parse().unwrap());
Ok((headers, html! {}))
}

View File

@@ -0,0 +1,79 @@
use crate::{
AppError, layouts,
models::{exercises::ExerciseVariant, workouts::WorkoutExerciseFull},
util::AppState,
};
use axum::{
Router,
extract::{Path, State},
routing::get,
};
use maud::{Markup, PreEscaped, html};
use uuid::Uuid;
pub fn routes() -> Router<AppState> {
Router::new().route("/", get(page))
}
async fn page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Markup, AppError> {
let exercises = sqlx::query_as!(
WorkoutExerciseFull,
r#"
SELECT exercises.exercise_id, exercise_type as "exercise_type:ExerciseVariant", sets, reps, time, name, description, position
FROM workout_exercises
JOIN exercises ON workout_exercises.exercise_id = exercises.exercise_id
WHERE workout_id = $1
ORDER BY position
"#,
&id,
).fetch_all(&state.pool).await?;
let exercises_json = serde_json::to_string(&exercises).unwrap();
let content = html! {
script {
"window.exerciseData = " (PreEscaped(exercises_json)) ";"
}
div x-data="exercisePlayer" {
@for exercise in exercises {
(display_exercise(exercise))
}
}
};
Ok(layouts::desktop(content, "Working out", true))
}
fn display_exercise(exercise: WorkoutExerciseFull) -> Markup {
html! {
div x-bind:class={"{ 'hidden': currentPosition !="(exercise.position)" }"} {
div class="flex justify-center items-center h-48" {
@match exercise.exercise_type {
ExerciseVariant::Time => div {
div {
span class="font-bold text-6xl" x-text="timeLeft" {}
span class="" { " / " }
span class="" {(exercise.time.unwrap_or(-1))}
}
},
ExerciseVariant::Number => div {
div { span class="font-bold text-6xl" {(exercise.sets.unwrap_or(-1))} " sets" }
div { span class="font-bold text-6xl" {(exercise.reps.unwrap_or(-1))} " reps" }
},
ExerciseVariant::Failure => div {
div { span class="font-bold text-6xl" { "Failure" } }
}
}
}
div class="flex justify-center gap-3" {
button class="btn" x-on:click="previous()" { "Previous" }
button class="btn" disabled[exercise.time.is_none()] x-on:click="pause()" { "Pause" }
button class="btn" x-on:click="next()" { "Next" }
}
div class="mt-5" {
h2 class="text-center font-bold text-4xl" { (exercise.name) }
p class="text-center desc" { (exercise.description) }
}
}
}
}