feat: implement simple file upload
							parent
							
								
									c10b9baa95
								
							
						
					
					
						commit
						52478379a0
					
				|  | @ -331,10 +331,12 @@ dependencies = [ | ||||||
|  "clap", |  "clap", | ||||||
|  "diesel", |  "diesel", | ||||||
|  "diesel_migrations", |  "diesel_migrations", | ||||||
|  |  "futures", | ||||||
|  "rand", |  "rand", | ||||||
|  "serde", |  "serde", | ||||||
|  "tera", |  "tera", | ||||||
|  "tokio", |  "tokio", | ||||||
|  |  "tokio-util", | ||||||
|  "tower-http", |  "tower-http", | ||||||
|  "tracing", |  "tracing", | ||||||
|  "tracing-subscriber", |  "tracing-subscriber", | ||||||
|  | @ -675,6 +677,21 @@ dependencies = [ | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "futures" | ||||||
|  | version = "0.3.31" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" | ||||||
|  | dependencies = [ | ||||||
|  |  "futures-channel", | ||||||
|  |  "futures-core", | ||||||
|  |  "futures-executor", | ||||||
|  |  "futures-io", | ||||||
|  |  "futures-sink", | ||||||
|  |  "futures-task", | ||||||
|  |  "futures-util", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "futures-channel" | name = "futures-channel" | ||||||
| version = "0.3.31" | version = "0.3.31" | ||||||
|  | @ -682,6 +699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "futures-core", |  "futures-core", | ||||||
|  |  "futures-sink", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -690,6 +708,34 @@ version = "0.3.31" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "futures-executor" | ||||||
|  | version = "0.3.31" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" | ||||||
|  | dependencies = [ | ||||||
|  |  "futures-core", | ||||||
|  |  "futures-task", | ||||||
|  |  "futures-util", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "futures-io" | ||||||
|  | version = "0.3.31" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "futures-macro" | ||||||
|  | version = "0.3.31" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" | ||||||
|  | dependencies = [ | ||||||
|  |  "proc-macro2", | ||||||
|  |  "quote", | ||||||
|  |  "syn", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "futures-sink" | name = "futures-sink" | ||||||
| version = "0.3.31" | version = "0.3.31" | ||||||
|  | @ -708,10 +754,16 @@ version = "0.3.31" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  |  "futures-channel", | ||||||
|  "futures-core", |  "futures-core", | ||||||
|  |  "futures-io", | ||||||
|  |  "futures-macro", | ||||||
|  |  "futures-sink", | ||||||
|  "futures-task", |  "futures-task", | ||||||
|  |  "memchr", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "pin-utils", |  "pin-utils", | ||||||
|  |  "slab", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
|  | @ -1534,6 +1586,15 @@ version = "0.3.11" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" | checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "slab" | ||||||
|  | version = "0.4.9" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" | ||||||
|  | dependencies = [ | ||||||
|  |  "autocfg", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "slug" | name = "slug" | ||||||
| version = "0.1.6" | version = "0.1.6" | ||||||
|  |  | ||||||
|  | @ -15,12 +15,14 @@ chrono = { version = "0.4.39", features = ["serde"] } | ||||||
| clap = { version = "4.5.26", features = ["derive", "env"] } | clap = { version = "4.5.26", features = ["derive", "env"] } | ||||||
| diesel = { version = "2.2.6", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "r2d2", "chrono"] } | diesel = { version = "2.2.6", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "r2d2", "chrono"] } | ||||||
| diesel_migrations = { version = "2.2.0", features = ["sqlite"] } | diesel_migrations = { version = "2.2.0", features = ["sqlite"] } | ||||||
|  | futures = "0.3.31" | ||||||
| rand = "0.8.5" | rand = "0.8.5" | ||||||
| # this dependency is needed soly because the r2d2_sqlite crate doesn't export | # this dependency is needed soly because the r2d2_sqlite crate doesn't export | ||||||
| # the 'chrono' feature flag | # the 'chrono' feature flag | ||||||
| serde = { version = "1.0.217", features = ["derive"] } | serde = { version = "1.0.217", features = ["derive"] } | ||||||
| tera = "1.20.0" | tera = "1.20.0" | ||||||
| tokio = { version = "1.42.0", features = ["full"] } | tokio = { version = "1.42.0", features = ["full"] } | ||||||
|  | tokio-util = { version = "0.7.13", features = ["io"] } | ||||||
| tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header", "fs"] } | tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header", "fs"] } | ||||||
| tracing = "0.1.41" | tracing = "0.1.41" | ||||||
| tracing-subscriber = "0.3.19" | tracing-subscriber = "0.3.19" | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| mod models; | mod models; | ||||||
|  | #[rustfmt::skip] | ||||||
| mod schema; | mod schema; | ||||||
| 
 | 
 | ||||||
| use diesel::{ | use diesel::{ | ||||||
|  | @ -10,6 +11,7 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; | ||||||
| use std::{error::Error, fmt, path::Path}; | use std::{error::Error, fmt, path::Path}; | ||||||
| 
 | 
 | ||||||
| pub use models::event::{Event, NewEvent, EVENT_TYPES}; | pub use models::event::{Event, NewEvent, EVENT_TYPES}; | ||||||
|  | pub use models::image::{Image, NewImage}; | ||||||
| pub use models::plant::{NewPlant, Plant}; | pub use models::plant::{NewPlant, Plant}; | ||||||
| pub use models::session::Session; | pub use models::session::Session; | ||||||
| pub use models::user::{NewUser, User}; | pub use models::user::{NewUser, User}; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
|  | use chrono::NaiveDate; | ||||||
| use diesel::prelude::*; | use diesel::prelude::*; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use chrono::NaiveDate; |  | ||||||
| 
 | 
 | ||||||
| use crate::db::{schema::*, DbPool, DbResult}; | use crate::db::{schema::*, DbPool, DbResult}; | ||||||
| 
 | 
 | ||||||
|  | @ -8,10 +8,10 @@ use crate::db::{schema::*, DbPool, DbResult}; | ||||||
| #[diesel(table_name = images)] | #[diesel(table_name = images)] | ||||||
| #[diesel(check_for_backend(diesel::sqlite::Sqlite))] | #[diesel(check_for_backend(diesel::sqlite::Sqlite))] | ||||||
| pub struct Image { | pub struct Image { | ||||||
|     id: i32, |     pub id: i32, | ||||||
|     plant_id: i32, |     pub plant_id: i32, | ||||||
|     date_taken: NaiveDate, |     pub date_taken: NaiveDate, | ||||||
|     note: Option<String>, |     pub note: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Deserialize, Insertable)] | #[derive(Deserialize, Insertable)] | ||||||
|  | @ -24,6 +24,14 @@ pub struct NewImage { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl NewImage { | impl NewImage { | ||||||
|  |     pub fn new(plant_id: i32, date_taken: NaiveDate, note: Option<String>) -> Self { | ||||||
|  |         Self { | ||||||
|  |             plant_id, | ||||||
|  |             date_taken, | ||||||
|  |             note, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     pub fn insert(self, pool: &DbPool) -> DbResult<Image> { |     pub fn insert(self, pool: &DbPool) -> DbResult<Image> { | ||||||
|         Ok(self |         Ok(self | ||||||
|             .insert_into(images::table) |             .insert_into(images::table) | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| pub mod event; | pub mod event; | ||||||
|  | pub mod image; | ||||||
| pub mod plant; | pub mod plant; | ||||||
| pub mod session; | pub mod session; | ||||||
| pub mod user; | pub mod user; | ||||||
| pub mod image; |  | ||||||
|  |  | ||||||
							
								
								
									
										13
									
								
								src/main.rs
								
								
								
								
							
							
						
						
									
										13
									
								
								src/main.rs
								
								
								
								
							|  | @ -2,7 +2,11 @@ mod cli; | ||||||
| mod db; | mod db; | ||||||
| mod server; | mod server; | ||||||
| 
 | 
 | ||||||
| use std::{fs, path::Path, sync::Arc}; | use std::{ | ||||||
|  |     fs, | ||||||
|  |     path::{Path, PathBuf}, | ||||||
|  |     sync::Arc, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| use clap::Parser; | use clap::Parser; | ||||||
| use tera::Tera; | use tera::Tera; | ||||||
|  | @ -12,11 +16,13 @@ use cli::UserCmd; | ||||||
| use db::DbError; | use db::DbError; | ||||||
| 
 | 
 | ||||||
| const DB_FILENAME: &str = "db.sqlite3"; | const DB_FILENAME: &str = "db.sqlite3"; | ||||||
|  | const IMG_DIR: &str = "imgs"; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct Context { | pub struct Context { | ||||||
|     pool: db::DbPool, |     pool: db::DbPool, | ||||||
|     tera: Arc<Tera>, |     tera: Arc<Tera>, | ||||||
|  |     data_dir: PathBuf, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn run_user_cli(data_dir: impl AsRef<Path>, cmd: UserCmd) -> Result<(), DbError> { | fn run_user_cli(data_dir: impl AsRef<Path>, cmd: UserCmd) -> Result<(), DbError> { | ||||||
|  | @ -59,6 +65,10 @@ async fn main() { | ||||||
|                 fs::create_dir_all(&args.data_dir).unwrap(); |                 fs::create_dir_all(&args.data_dir).unwrap(); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  |             if !fs::exists(args.data_dir.join(IMG_DIR)).unwrap() { | ||||||
|  |                 fs::create_dir(args.data_dir.join(IMG_DIR)).unwrap(); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|             let pool = db::initialize_db(args.data_dir.join(DB_FILENAME), true).unwrap(); |             let pool = db::initialize_db(args.data_dir.join(DB_FILENAME), true).unwrap(); | ||||||
| 
 | 
 | ||||||
|             let tera = |             let tera = | ||||||
|  | @ -67,6 +77,7 @@ async fn main() { | ||||||
|             let ctx = Context { |             let ctx = Context { | ||||||
|                 pool, |                 pool, | ||||||
|                 tera: Arc::new(tera), |                 tera: Arc::new(tera), | ||||||
|  |                 data_dir: args.data_dir.clone(), | ||||||
|             }; |             }; | ||||||
|             let app = server::app(ctx, &args.static_dir) |             let app = server::app(ctx, &args.static_dir) | ||||||
|                 .layer(CompressionLayer::new().br(true).gzip(true)); |                 .layer(CompressionLayer::new().br(true).gzip(true)); | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| use std::fmt::{self, Write}; | use std::fmt::{self, Write}; | ||||||
| 
 | 
 | ||||||
| use axum::{http::StatusCode, response::IntoResponse}; | use axum::{extract::multipart::MultipartError, http::StatusCode, response::IntoResponse}; | ||||||
| 
 | 
 | ||||||
| use crate::db; | use crate::db; | ||||||
| 
 | 
 | ||||||
|  | @ -8,6 +8,9 @@ use crate::db; | ||||||
| pub enum AppError { | pub enum AppError { | ||||||
|     Db(db::DbError), |     Db(db::DbError), | ||||||
|     Tera(tera::Error), |     Tera(tera::Error), | ||||||
|  |     Multipart(MultipartError), | ||||||
|  |     IO(std::io::Error), | ||||||
|  |     BadRequest, | ||||||
|     Unauthorized, |     Unauthorized, | ||||||
|     NotFound, |     NotFound, | ||||||
| } | } | ||||||
|  | @ -17,6 +20,9 @@ impl fmt::Display for AppError { | ||||||
|         match self { |         match self { | ||||||
|             Self::Db(_) => write!(f, "database error"), |             Self::Db(_) => write!(f, "database error"), | ||||||
|             Self::Tera(_) => write!(f, "error rendering template"), |             Self::Tera(_) => write!(f, "error rendering template"), | ||||||
|  |             Self::Multipart(_) => write!(f, "error processing multipart request"), | ||||||
|  |             Self::IO(_) => write!(f, "io error"), | ||||||
|  |             Self::BadRequest => write!(f, "bad request"), | ||||||
|             Self::Unauthorized => write!(f, "unauthorized"), |             Self::Unauthorized => write!(f, "unauthorized"), | ||||||
|             Self::NotFound => write!(f, "not found"), |             Self::NotFound => write!(f, "not found"), | ||||||
|         } |         } | ||||||
|  | @ -28,7 +34,9 @@ impl std::error::Error for AppError { | ||||||
|         match self { |         match self { | ||||||
|             Self::Db(err) => Some(err), |             Self::Db(err) => Some(err), | ||||||
|             Self::Tera(err) => Some(err), |             Self::Tera(err) => Some(err), | ||||||
|             Self::NotFound | Self::Unauthorized => None, |             Self::IO(err) => Some(err), | ||||||
|  |             Self::Multipart(err) => Some(err), | ||||||
|  |             Self::NotFound | Self::Unauthorized | Self::BadRequest => None, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -63,11 +71,25 @@ impl From<tera::Error> for AppError { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl From<MultipartError> for AppError { | ||||||
|  |     fn from(value: MultipartError) -> Self { | ||||||
|  |         Self::Multipart(value) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl From<std::io::Error> for AppError { | ||||||
|  |     fn from(value: std::io::Error) -> Self { | ||||||
|  |         Self::IO(value) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| impl IntoResponse for AppError { | impl IntoResponse for AppError { | ||||||
|     fn into_response(self) -> axum::response::Response { |     fn into_response(self) -> axum::response::Response { | ||||||
|         match self { |         match self { | ||||||
|             Self::NotFound => StatusCode::NOT_FOUND.into_response(), |             Self::NotFound => StatusCode::NOT_FOUND.into_response(), | ||||||
|             Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(), |             Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(), | ||||||
|  |             Self::BadRequest => StatusCode::BAD_REQUEST.into_response(), | ||||||
|  |             Self::Multipart(err) => err.into_response(), | ||||||
|             _ => { |             _ => { | ||||||
|                 tracing::error!("{}", self.stack()); |                 tracing::error!("{}", self.stack()); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,93 @@ | ||||||
|  | use axum::{ | ||||||
|  |     extract::{multipart::Field, Multipart, State}, | ||||||
|  |     response::Html, | ||||||
|  |     routing::post, | ||||||
|  |     Router, | ||||||
|  | }; | ||||||
|  | use chrono::NaiveDate; | ||||||
|  | use futures::TryStreamExt; | ||||||
|  | use tokio_util::io::StreamReader; | ||||||
|  | 
 | ||||||
|  | use super::error::AppError; | ||||||
|  | use crate::db::NewImage; | ||||||
|  | 
 | ||||||
|  | pub fn app() -> axum::Router<crate::Context> { | ||||||
|  |     Router::new().route("/", post(post_image)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async fn post_image( | ||||||
|  |     State(ctx): State<crate::Context>, | ||||||
|  |     mut mt: Multipart, | ||||||
|  | ) -> super::Result<Html<String>> { | ||||||
|  |     let mut plant_id: Option<i32> = None; | ||||||
|  |     let mut date_taken: Option<NaiveDate> = None; | ||||||
|  |     let mut note: Option<String> = None; | ||||||
|  | 
 | ||||||
|  |     while let Some(field) = mt.next_field().await? { | ||||||
|  |         match field.name() { | ||||||
|  |             Some("plant_id") => { | ||||||
|  |                 plant_id = Some( | ||||||
|  |                     field | ||||||
|  |                         .text() | ||||||
|  |                         .await? | ||||||
|  |                         .parse() | ||||||
|  |                         .map_err(|_| AppError::BadRequest)?, | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             Some("date_taken") => { | ||||||
|  |                 date_taken = Some( | ||||||
|  |                     field | ||||||
|  |                         .text() | ||||||
|  |                         .await? | ||||||
|  |                         .parse() | ||||||
|  |                         .map_err(|_| AppError::BadRequest)?, | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             Some("note") => { | ||||||
|  |                 note = Some(field.text().await?); | ||||||
|  |             } | ||||||
|  |             Some("image") => { | ||||||
|  |                 // These fields are required to be provided before the image field (note is
 | ||||||
|  |                 // optional)
 | ||||||
|  |                 if plant_id.is_none() || date_taken.is_none() { | ||||||
|  |                     return Err(AppError::BadRequest); | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 receive_image(ctx, plant_id.unwrap(), date_taken.unwrap(), note, field).await?; | ||||||
|  | 
 | ||||||
|  |                 return Ok(Html(String::new())); | ||||||
|  |             } | ||||||
|  |             _ => { | ||||||
|  |                 return Err(AppError::BadRequest); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     Err(AppError::BadRequest) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async fn receive_image<'a>( | ||||||
|  |     ctx: crate::Context, | ||||||
|  |     plant_id: i32, | ||||||
|  |     date_taken: NaiveDate, | ||||||
|  |     note: Option<String>, | ||||||
|  |     field: Field<'a>, | ||||||
|  | ) -> super::Result<()> { | ||||||
|  |     let image = tokio::task::spawn_blocking(move || { | ||||||
|  |         NewImage::new(plant_id, date_taken, note).insert(&ctx.pool) | ||||||
|  |     }) | ||||||
|  |     .await | ||||||
|  |     .unwrap()?; | ||||||
|  | 
 | ||||||
|  |     let mut r = StreamReader::new(field.map_err(std::io::Error::other)); | ||||||
|  |     let mut f = tokio::fs::File::create( | ||||||
|  |         ctx.data_dir | ||||||
|  |             .join(crate::IMG_DIR) | ||||||
|  |             .join(image.id.to_string()) | ||||||
|  |             .with_extension("png"), | ||||||
|  |     ) | ||||||
|  |     .await?; | ||||||
|  |     tokio::io::copy(&mut r, &mut f).await?; | ||||||
|  | 
 | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| mod auth; | mod auth; | ||||||
| mod error; | mod error; | ||||||
| mod events; | mod events; | ||||||
|  | mod images; | ||||||
| mod plants; | mod plants; | ||||||
| 
 | 
 | ||||||
| use std::path::Path; | use std::path::Path; | ||||||
|  | @ -55,6 +56,7 @@ pub fn app(ctx: crate::Context, static_dir: impl AsRef<Path>) -> axum::Router { | ||||||
|     let router = Router::new() |     let router = Router::new() | ||||||
|         .nest("/plants", plants::app()) |         .nest("/plants", plants::app()) | ||||||
|         .nest("/events", events::app()) |         .nest("/events", events::app()) | ||||||
|  |         .nest("/images", images::app()) | ||||||
|         .layer(middleware::from_fn_with_state( |         .layer(middleware::from_fn_with_state( | ||||||
|             ctx.clone(), |             ctx.clone(), | ||||||
|             auth::auth_middleware, |             auth::auth_middleware, | ||||||
|  |  | ||||||
|  | @ -0,0 +1,16 @@ | ||||||
|  | {% macro form(plant_id, target="#images > ul") %} | ||||||
|  | <form hx-post="/images" hx-target="{{ target }}" hx-swap="beforeend" enctype="multipart/form-data"> | ||||||
|  |     <input type="hidden" id="plant_id" name="plant_id" value="{{ plant_id }}"> | ||||||
|  | 
 | ||||||
|  |     <label for="date_taken">Date taken:</label> | ||||||
|  |     <input type="date" id="date_taken" name="date_taken"></br> | ||||||
|  | 
 | ||||||
|  |     <label for="note">Note:</label> | ||||||
|  |     <textarea id="note" name="note" rows=1></textarea></br> | ||||||
|  | 
 | ||||||
|  |     <label for="image">Image:</label> | ||||||
|  |     <input type="file" id="image" name="image" accept="image/*" capture="environment"></br> | ||||||
|  | 
 | ||||||
|  |     <input type="submit"> | ||||||
|  | </form> | ||||||
|  | {% endmacro form %} | ||||||
|  | @ -1,7 +1,13 @@ | ||||||
| {% import "components/event.html" as comp_event %} | {% import "components/event.html" as comp_event %} | ||||||
| {% import "components/plant.html" as comp_plant %} | {% import "components/plant.html" as comp_plant %} | ||||||
|  | {% import "components/image.html" as comp_image %} | ||||||
| 
 | 
 | ||||||
| {{ comp_plant::info(plant=plant) }} | {{ comp_plant::info(plant=plant) }} | ||||||
| <h3>Events</h3> | <h3>Events</h3> | ||||||
| {{ comp_event::list(events=events) }} | {{ comp_event::list(events=events) }} | ||||||
| {{ comp_event::form(plant_id=plant.id) }} | {{ comp_event::form(plant_id=plant.id) }} | ||||||
|  | <h3>Images</h3> | ||||||
|  | <div id="images"> | ||||||
|  |     <ul></ul> | ||||||
|  | </div> | ||||||
|  | {{ comp_image::form(plant_id=plant.id) }} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue