This commit is contained in:
eraden 2024-11-01 06:21:50 +01:00
parent 80d3a4d4b2
commit 9d5dc91a22
8 changed files with 231 additions and 11 deletions

55
Cargo.lock generated
View File

@ -134,6 +134,44 @@ dependencies = [
"syn 2.0.82", "syn 2.0.82",
] ]
[[package]]
name = "actix-multipart"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53"
dependencies = [
"actix-multipart-derive",
"actix-utils",
"actix-web",
"derive_more 0.99.18",
"futures-core",
"futures-util",
"httparse",
"local-waker",
"log",
"memchr",
"mime",
"rand",
"serde",
"serde_json",
"serde_plain",
"tempfile",
"tokio",
]
[[package]]
name = "actix-multipart-derive"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b"
dependencies = [
"darling",
"parse-size",
"proc-macro2",
"quote",
"syn 2.0.82",
]
[[package]] [[package]]
name = "actix-router" name = "actix-router"
version = "0.5.3" version = "0.5.3"
@ -1126,6 +1164,7 @@ dependencies = [
"actix", "actix",
"actix-files", "actix-files",
"actix-identity", "actix-identity",
"actix-multipart",
"actix-session", "actix-session",
"actix-web", "actix-web",
"askama", "askama",
@ -1350,6 +1389,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim",
"syn 2.0.82", "syn 2.0.82",
] ]
@ -2915,6 +2955,12 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "parse-size"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b"
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -4134,6 +4180,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"

View File

@ -30,6 +30,7 @@ tantivy = "0.22.0"
tempfile = "3.13.0" tempfile = "3.13.0"
pulldown-cmark = "0.12.2" pulldown-cmark = "0.12.2"
thiserror = "1.0.65" thiserror = "1.0.65"
actix-multipart = "0.7.2"
[build-dependencies] [build-dependencies]

View File

@ -1180,6 +1180,60 @@ html{
} }
} }
.file-input{
height: 3rem;
flex-shrink: 1;
padding-inline-end: 1rem;
font-size: 1rem;
line-height: 2;
line-height: 1.5rem;
overflow: hidden;
border-radius: var(--rounded-btn, 0.5rem);
border-width: 1px;
border-color: var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));
--tw-border-opacity: 0;
--tw-bg-opacity: 1;
background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));
}
.file-input::file-selector-button{
margin-inline-end: 1rem;
display: inline-flex;
height: 100%;
flex-shrink: 0;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding-left: 1rem;
padding-right: 1rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.25rem;
line-height: 1em;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: 200ms;
border-style: solid;
--tw-border-opacity: 1;
border-color: var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));
--tw-bg-opacity: 1;
background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));
font-weight: 600;
text-transform: uppercase;
--tw-text-opacity: 1;
color: var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));
text-decoration-line: none;
border-width: var(--border-btn, 1px);
animation: button-pop var(--animation-btn, 0.25s) ease-out;
}
.label{ .label{
display: flex; display: flex;
-webkit-user-select: none; -webkit-user-select: none;
@ -1671,6 +1725,42 @@ details.collapse summary::-webkit-details-marker{
gap: 1rem; gap: 1rem;
} }
.file-input:focus{
outline-style: solid;
outline-width: 2px;
outline-offset: 2px;
outline-color: var(--fallback-bc,oklch(var(--bc)/0.2));
}
.file-input-disabled,
.file-input[disabled]{
cursor: not-allowed;
--tw-border-opacity: 1;
border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));
--tw-bg-opacity: 1;
background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));
--tw-text-opacity: 0.2;
}
.file-input-disabled::-moz-placeholder, .file-input[disabled]::-moz-placeholder{
color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));
--tw-placeholder-opacity: 0.2;
}
.file-input-disabled::placeholder,
.file-input[disabled]::placeholder{
color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));
--tw-placeholder-opacity: 0.2;
}
.file-input-disabled::file-selector-button, .file-input[disabled]::file-selector-button{
--tw-border-opacity: 0;
background-color: var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));
--tw-bg-opacity: 0.2;
color: var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));
--tw-text-opacity: 0.2;
}
.input input{ .input input{
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity))); background-color: var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));
@ -2299,6 +2389,10 @@ details.collapse summary::-webkit-details-marker{
width: 100%; width: 100%;
} }
.max-w-xs{
max-width: 20rem;
}
.shrink-0{ .shrink-0{
flex-shrink: 0; flex-shrink: 0;
} }
@ -2435,6 +2529,10 @@ details.collapse summary::-webkit-details-marker{
padding: 3rem; padding: 3rem;
} }
.p-2{
padding: 0.5rem;
}
.p-4{ .p-4{
padding: 1rem; padding: 1rem;
} }
@ -2906,6 +3004,18 @@ code{
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
} }
.styled-checkbox:has(input:checked){
--tw-border-opacity: 1;
border-color: rgb(21 128 61 / var(--tw-border-opacity));
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity));
}
.styled-checkbox:has(input:not(:checked)){
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
}
.marker\:text-rose-900 *::marker{ .marker\:text-rose-900 *::marker{
color: rgb(136 19 55 ); color: rgb(136 19 55 );
} }

View File

@ -2,6 +2,7 @@ use crate::actors::search::{Find, Search};
use crate::types::*; use crate::types::*;
use crate::{entities, entities::prelude::*, filters}; use crate::{entities, entities::prelude::*, filters};
use actix_identity::Identity; use actix_identity::Identity;
use actix_multipart::form::{tempfile::TempFile, MultipartForm};
use actix_web::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; use actix_web::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use actix_web::web::{Data, Form, Path}; use actix_web::web::{Data, Form, Path};
use actix_web::HttpMessage; use actix_web::HttpMessage;
@ -56,7 +57,12 @@ struct SignInForm {
page: Page, page: Page,
} }
#[derive(Debug, Template, Deserialize, Clone)] #[derive(Debug, MultipartForm)]
struct ImageUpload {
image: Option<TempFile>,
}
#[derive(Debug, Template, Deserialize, Clone, PartialEq)]
#[template(path = "recipies/create_form.jinja", ext = "html")] #[template(path = "recipies/create_form.jinja", ext = "html")]
struct RecipeForm { struct RecipeForm {
title: String, title: String,
@ -64,10 +70,14 @@ struct RecipeForm {
ingeredients: String, ingeredients: String,
steps: String, steps: String,
tags: String, tags: String,
#[serde(default)]
selected_tags: Vec<i32>,
image_url: String, image_url: String,
time: Option<String>, time: Option<String>,
author: Option<String>, author: Option<String>,
error: Option<String>, error: Option<String>,
#[serde(default)]
known_tags: Vec<entities::tags::Model>,
session: Option<User>, session: Option<User>,
page: Page, page: Page,
} }
@ -304,17 +314,20 @@ async fn show(
} }
#[get("/create")] #[get("/create")]
async fn recipe_form(admin: Identity) -> RecipeForm { async fn recipe_form(admin: Identity, db: Data<DatabaseConnection>) -> RecipeForm {
let tags = Tags::find().all(&**db).await.unwrap_or_default();
RecipeForm { RecipeForm {
title: "".into(), title: "".into(),
summary: "".into(), summary: "".into(),
ingeredients: "".into(), ingeredients: "".into(),
steps: "".into(), steps: "".into(),
tags: "".into(), tags: "".into(),
selected_tags: Vec::new(),
image_url: "".into(), image_url: "".into(),
author: None, author: None,
time: None, time: None,
error: None, error: None,
known_tags: tags,
session: admin.id().ok(), session: admin.id().ok(),
page: Page::Index, page: Page::Index,
} }
@ -338,6 +351,7 @@ async fn create_recipe(
Err(e) => { Err(e) => {
failure.error = Some(e.to_string()); failure.error = Some(e.to_string());
let _ = t.rollback().await; let _ = t.rollback().await;
failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default();
Ok(failure.to_response()) Ok(failure.to_response())
} }
Ok(_) => { Ok(_) => {

View File

@ -14,6 +14,11 @@
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center"> <div class="flex flex-wrap gap-8 justify-center">
<form action="/create" method="post" class="flex flex-col gap-4 md:w-1/2 md:mb-10"> <form action="/create" method="post" class="flex flex-col gap-4 md:w-1/2 md:mb-10">
<label for="image" class="flex gap-4">
<input type="file" class="file-input w-full max-w-xs" name="image" id="image" onchange="{ const f = new FormData(); f.append('image', this.files[0]); fetch('/upload', { body: f, method: 'post' }).then(res = res.json()).then(res => document.body.querySelector('#image_url').res.image_url); }" />
<input name="image_url" id='image_url' class="hidden" />
</label>
<label for="title" class="flex gap-4"> <label for="title" class="flex gap-4">
<span class="w-24 shrink-0">Title:</span> <span class="w-24 shrink-0">Title:</span>
<input id="title" name="title" class="input border border-solid border-gray-200 rounded-lg" type="text" value="{{title}}" /> <input id="title" name="title" class="input border border-solid border-gray-200 rounded-lg" type="text" value="{{title}}" />
@ -66,11 +71,16 @@
<label for="tags" class="flex gap-4"> <label for="tags" class="flex gap-4">
<span class="w-24 shrink-0">Tags:</span> <span class="w-24 shrink-0">Tags:</span>
<div class="flex gap-2 w-full"> <input id="tags" name="tags" class="textarea-with-view" value="{{tags}}" />
<textarea id="tags" name="tags" class="textarea-with-view">{{tags}}</textarea>
<div class="content w-1/2">&nbsp;</div>
</div>
</label> </label>
<div class="w-full flex gap-4 flex-wrap">
{% for tag in known_tags %}
<label class="border rounded-lg p-2 styled-checkbox cursor-pointer">
<span>{{ tag.name }}</span>
<input type="checkbox" name="selected_tags[{{loop.index0}}]" value="{{tag.id}}" class="hidden" />
</label>
{% endfor %}
</div>
<div> <div>
<input type="submit" class="btn w-full" value="Save" /> <input type="submit" class="btn w-full" value="Save" />
</div> </div>

View File

@ -58,3 +58,11 @@ code {
.textarea-without-view { .textarea-without-view {
@apply min-h-40 input border border-solid border-gray-200 rounded-lg w-full; @apply min-h-40 input border border-solid border-gray-200 rounded-lg w-full;
} }
.styled-checkbox:has(input:checked) {
@apply border-green-700;
@apply text-green-700;
}
.styled-checkbox:has(input:not(:checked)) {
@apply border-gray-400;
}

View File

@ -19,7 +19,7 @@ impl MigrationTrait for Migration {
.auto_increment() .auto_increment()
.primary_key(), .primary_key(),
) )
.col(ColumnDef::new(Tag::Name).string().not_null()) .col(ColumnDef::new(Tag::Name).string().not_null().unique_key())
.to_owned(), .to_owned(),
) )
.await?; .await?;
@ -36,7 +36,12 @@ impl MigrationTrait for Migration {
.auto_increment() .auto_increment()
.primary_key(), .primary_key(),
) )
.col(ColumnDef::new(Ingredient::Name).string().not_null()) .col(
ColumnDef::new(Ingredient::Name)
.string()
.not_null()
.unique_key(),
)
.to_owned(), .to_owned(),
) )
.await?; .await?;

View File

@ -21,12 +21,12 @@ async fn main() {
let mut t = db.begin().await.unwrap(); let mut t = db.begin().await.unwrap();
let mut tags = Vec::with_capacity(30); let mut tags = Vec::with_capacity(15);
for _ in 0..30 { for name in TAGS {
use entities::tags::ActiveModel; use entities::tags::ActiveModel;
let r = Tags::insert(ActiveModel { let r = Tags::insert(ActiveModel {
id: NotSet, id: NotSet,
name: Set(fake::faker::name::en::FirstName().fake()), name: Set(name.to_string()),
}) })
.exec_with_returning(&mut t) .exec_with_returning(&mut t)
.await; .await;
@ -327,3 +327,20 @@ const DISHES: [&'static str; 96] = [
]; ];
const UNIT: [&'static str; 6] = ["kg", "g", "łyżka stołowa", "łyżeczka", "szt", "szklanki"]; const UNIT: [&'static str; 6] = ["kg", "g", "łyżka stołowa", "łyżeczka", "szt", "szklanki"];
const TAGS: [&'static str; 14] = [
"Main Dishes",
"Soups",
"Pasta",
"Sweet",
"Breads",
"Dips",
"Baking",
"Asian",
"Beef Recipes",
"Chicken",
"Lamb Recipes",
"Breakfast",
"Muffin Recipes",
"Egg Recipes",
];