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

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