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