Add image handler

This commit is contained in:
eraden 2022-07-07 22:45:30 +02:00
parent 4ce82d15fb
commit 25986ec594
12 changed files with 281 additions and 52 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
node_modules
uploads
dist

35
Cargo.lock generated
View File

@ -143,6 +143,24 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-multipart"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9edfb0e7663d7fe18c8d5b668c9c1bcf79176b1dcc9d4da9592503209a6bfb0"
dependencies = [
"actix-utils",
"actix-web",
"bytes",
"derive_more",
"futures-core",
"httparse",
"local-waker",
"log",
"mime",
"twoway",
]
[[package]]
name = "actix-router"
version = "0.5.0"
@ -1282,6 +1300,7 @@ dependencies = [
"actix-files",
"actix-http",
"actix-identity",
"actix-multipart",
"actix-rt",
"actix-utils",
"actix-web",
@ -2080,12 +2099,28 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "twoway"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47"
dependencies = [
"memchr",
"unchecked-index",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unchecked-index"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c"
[[package]]
name = "unicase"
version = "2.6.0"

View File

@ -5,26 +5,27 @@ edition = "2021"
[dependencies]
actix = { version = "*" }
actix-web = { version = "*" }
actix-http = { version = "3.2.1" }
actix-cors = { version = "*" }
actix-files = { version = "*" }
actix-http = { version = "3.2.1" }
actix-identity = { version = "0.4.0" }
actix-multipart = { version = "0.4.0" }
actix-rt = { version = "*" }
actix-utils = { version = "3.0.0" }
actix-files = { version = "*" }
actix-identity = { version = "0.4.0" }
actix-web = { version = "*" }
argon2 = { version = "0.4.1" }
askama = { version = "*" }
validator = { version = "0.14", features = ["derive"] }
chrono = { version = "*", features = ["serde"] }
futures = { version = "0.3.21", features = ["async-await", "std"] }
futures-util = { version = "0.3.21", features = [] }
gumdrop = { version = "*" }
password-hash = { version = "0.4.2" }
rand = { version = "0.8.5", features = [] }
serde = { version = "*", features = ["derive"] }
serde_json = { version = "*" }
sqlx = { version = "*", features = ["runtime-actix-rustls", "postgres", "uuid", "chrono"] }
uuid = { version = "*", features = ["serde"] }
chrono = { version = "*", features = ["serde"] }
gumdrop = { version = "*" }
tracing = { version = "*" }
tracing-subscriber = { version = "*" }
tracing-actix-web = { version = "*" }
argon2 = { version = "0.4.1" }
password-hash = { version = "0.4.2" }
rand = { version = "0.8.5", features = [] }
futures = { version = "0.3.21", features = ["async-await", "std"] }
futures-util = { version = "0.3.21", features = [] }
tracing-subscriber = { version = "*" }
uuid = { version = "*", features = ["serde"] }
validator = { version = "0.14", features = ["derive"] }

View File

@ -2,7 +2,11 @@
{% block content %}
<business-items>
{% for item in items %}
<business-item name="{{item.name}}" price="{{item.price}}">
<business-item
name="{{item.name}}"
price="{{item.price}}"
url="{{item.picture_url}}"
>
</business-item>
{% endfor %}

88
client/dist/app.js vendored
View File

@ -1266,6 +1266,14 @@ customElements.define("register-form", class extends HTMLElement {
}
});
customElements.define("image-input", class extends HTMLElement {
static get observedAttributes() {
return [
"width",
"height",
"account-id",
"url"
];
}
constructor(){
super();
let b = this[S] = this.attachShadow({
@ -1275,7 +1283,7 @@ customElements.define("image-input", class extends HTMLElement {
<style>
:host { display: block; border: 1px solid black; }
#hidden { overflow: hidden; width: 1px; height: 1px; position: relative; }
input { position: absolute; top: -10px; left: -10px; display: none; }
input[type=file] { position: absolute; top: -10px; left: -10px; display: none; }
#view { width: 200px; height: 200px; cursor: pointer; }
canvas { width: 200px; height: 200px; }
</style>
@ -1284,13 +1292,31 @@ customElements.define("image-input", class extends HTMLElement {
<input id="file" type="file" accept="image/*" />
<img alt="" src="" />
</section>
<div id="view"><canvas width="200" height="200"></canvas></div>
<div id="view">
<canvas width="200" height="200"></canvas>
</div>
<div>
<input id="save" type="button" value="Zapisz" />
</div>
</article>
`;
`, b.querySelector("#save").addEventListener("click", (a)=>{
a.preventDefault(), a.stopPropagation();
let b = atob(g.toDataURL("image/webp", 1.0).split(",")[1]), c = [];
for(let d = 0; d < b.length; d++)c.push(b.charCodeAt(d));
let e = new Blob([
new Uint8Array(c)
], {
type: "image/webp"
}), f = new FormData;
f.append(`${crypto.randomUUID()}.webp`, e), fetch("/upload", {
method: "POST",
body: f
}).then((a)=>a.json()).then(({ path: a })=>this.url = a);
});
let c = new FileReader(), d = b.querySelector("#file"), e = b.querySelector("#view"), f = b.querySelector("img"), g = b.querySelector("canvas"), h = g.getContext("2d");
f.addEventListener("load", ()=>{
let a, b;
f.width > f.height ? (a = 200, b = 200 * f.height / f.width) : (a = 200 * f.width / f.height, b = 200), console.log(f.width, f.height), console.log(a, b), f.width = a, f.height = b, h.fillStyle = "#F00", h.rect(0, 0, 200, 200), h.drawImage(f, 0, 0, a, b);
f.width > f.height ? (a = 200, b = 200 * f.height / f.width) : (a = 200 * f.width / f.height, b = 200), this.setAttribute("width", a), this.setAttribute("height", b), f.width = a, f.height = b, h.clearRect(0, 0, 200, 200), h.drawImage(f, 0, 0, a, b);
}), d.addEventListener("change", (a)=>{
a.stopPropagation(), c.addEventListener("loadend", (a)=>{
a.total === a.loaded && (f.src = a.target.result || "");
@ -1299,6 +1325,37 @@ customElements.define("image-input", class extends HTMLElement {
a.stopPropagation(), d.click();
});
}
connectedCallback() {
this.account_id = this.account_id, this.url = this.url;
}
attributeChangedCallback(a, b, c) {
if (b !== c) switch(a){
case "account-id":
return this.account_id = c;
case "url":
return this.url = c;
}
}
get account_id() {
return this.getAttribute("account-id");
}
set account_id(a) {
this.setAttribute("account-id", a);
}
get width() {
let a = parseInt(this.getAttribute("width"));
return isNaN(a) ? 0 : a;
}
get height() {
let a = parseInt(this.getAttribute("height"));
return isNaN(a) ? 0 : a;
}
get url() {
return this.getAttribute("url");
}
set url(b) {
this.setAttribute("url", b), this[S].querySelector("img").src = b;
}
});
customElements.define("business-item", class extends HTMLElement {
static get observedAttributes() {
@ -1326,28 +1383,35 @@ customElements.define("business-item", class extends HTMLElement {
`;
}
connectedCallback() {
this.filter = this.getAttribute("filter");
this.name = this.name, this.price = this.price, this.picture_url = this.picture_url;
}
attributeChangedCallback(a, b, c) {
if (b !== c && "filter" === a) return this.filter = c;
if (b !== c) switch(a){
case "name":
return this.name = c;
case "price":
return this.price = c / 100.0;
case "picture-url":
return this.picture_url = c;
}
}
get name() {
return this.getAttribute("name");
}
set name(a) {
this.setAttribute("name", a), this.querySelector("#name").textContent = a;
set name(b) {
this.setAttribute("name", b), this[S].querySelector("#name").textContent = b;
}
get price() {
return this.getAttribute("price");
}
set price(a) {
this.setAttribute("price", a), this.querySelector("price-input").value = a;
set price(b) {
this.setAttribute("price", b), this[S].querySelector("price-input").value = b;
}
get picture_url() {
return this.getAttribute("picture-url");
}
set picture_url(a) {
this.setAttribute("picture-url", a), this.querySelector("image-input").src = a;
set picture_url(b) {
this.setAttribute("picture-url", b), this[S].querySelector("image-input").src = b;
}
});
customElements.define("business-items", class extends HTMLElement {

File diff suppressed because one or more lines are too long

View File

@ -25,14 +25,17 @@ customElements.define('business-item', class extends HTMLElement {
}
connectedCallback() {
this.filter = this.getAttribute('filter');
this.name = this.name;
this.price = this.price;
this.picture_url = this.picture_url;
}
attributeChangedCallback(name, oldV, newV) {
if (oldV === newV) return;
switch (name) {
case 'filter':
return this.filter = newV;
case 'name': return this.name = newV;
case 'price': return this.price = newV / 100.0;
case 'picture-url': return this.picture_url = newV;
}
}
@ -41,7 +44,7 @@ customElements.define('business-item', class extends HTMLElement {
}
set name(v) {
this.setAttribute('name', v);
this.querySelector('#name').textContent = v;
this[S].querySelector('#name').textContent = v;
}
@ -50,7 +53,7 @@ customElements.define('business-item', class extends HTMLElement {
}
set price(v) {
this.setAttribute('price', v);
this.querySelector('price-input').value = v;
this[S].querySelector('price-input').value = v;
}
@ -59,6 +62,6 @@ customElements.define('business-item', class extends HTMLElement {
}
set picture_url(v) {
this.setAttribute('picture-url', v);
this.querySelector('image-input').src = v;
this[S].querySelector('image-input').src = v;
}
});

View File

@ -1,6 +1,10 @@
import { S } from "../shared.js";
customElements.define('image-input', class extends HTMLElement {
static get observedAttributes() {
return ['width', 'height', "account-id", "url"]
}
constructor() {
super();
@ -10,7 +14,7 @@ customElements.define('image-input', class extends HTMLElement {
<style>
:host { display: block; border: 1px solid black; }
#hidden { overflow: hidden; width: 1px; height: 1px; position: relative; }
input { position: absolute; top: -10px; left: -10px; display: none; }
input[type=file] { position: absolute; top: -10px; left: -10px; display: none; }
#view { width: 200px; height: 200px; cursor: pointer; }
canvas { width: 200px; height: 200px; }
</style>
@ -19,10 +23,32 @@ customElements.define('image-input', class extends HTMLElement {
<input id="file" type="file" accept="image/*" />
<img alt="" src="" />
</section>
<div id="view"><canvas width="200" height="200"></canvas></div>
<div id="view">
<canvas width="200" height="200"></canvas>
</div>
<div>
<input id="save" type="button" value="Zapisz" />
</div>
</article>
`;
shadow.querySelector('#save').addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
const blobBin = atob(canvas.toDataURL("image/webp", 1.0).split(',')[1]);
const array = [];
for (let i = 0; i < blobBin.length; i++) {
array.push(blobBin.charCodeAt(i));
}
const file = new Blob([new Uint8Array(array)], { type: 'image/webp' });
const form = new FormData;
form.append(`${ crypto.randomUUID() }.webp`, file);
fetch("/upload", {
method: "POST",
body: form,
}).then(res => res.json()).then(({ path }) => this.url = path);
});
const f = new FileReader();
const input = shadow.querySelector('#file');
const view = shadow.querySelector('#view');
@ -39,13 +65,12 @@ customElements.define('image-input', class extends HTMLElement {
width = (img.width * 200) / img.height;
height = 200;
}
console.log(img.width, img.height);
console.log(width, height);
this.setAttribute('width', width);
this.setAttribute('height', height);
img.width = width;
img.height = height;
// ctx.drawImage(img, 0, 0);
ctx.fillStyle = '#F00';
ctx.rect(0, 0, 200, 200);
ctx.clearRect(0, 0, 200, 200);
ctx.drawImage(img, 0, 0, width, height);
});
input.addEventListener('change', ev => {
@ -64,4 +89,46 @@ customElements.define('image-input', class extends HTMLElement {
input.click();
});
}
connectedCallback() {
this.account_id = this.account_id;
this.url = this.url;
}
attributeChangedCallback(name, oldV, newV) {
if (oldV === newV) return;
switch (name) {
case 'account-id':
return this.account_id = newV;
case 'url':
return this.url = newV;
}
}
get account_id() {
return this.getAttribute('account-id');
}
set account_id(v) {
this.setAttribute('account-id', v);
}
get width() {
const v = parseInt(this.getAttribute('width'));
return isNaN(v) ? 0 : v;
}
get height() {
const v = parseInt(this.getAttribute('height'));
return isNaN(v) ? 0 : v;
}
get url() {
return this.getAttribute('url');
}
set url(v) {
this.setAttribute('url', v);
this[S].querySelector('img').src = v;
}
});

View File

@ -0,0 +1,2 @@
ALTER TABLE local_business_items
ADD COLUMN picture_url TEXT NOT NULL UNIQUE;

View File

@ -78,4 +78,5 @@ pub struct LocalBusinessItem {
pub name: String,
pub price: i64,
pub item_order: i32,
pub picture_url: String,
}

View File

@ -49,19 +49,21 @@ impl Page {
pub struct BusinessItemInput {
pub name: String,
pub price: u32,
pub picture_url: String,
}
impl BusinessItemInput {
pub fn new<S: Into<String>>(name: S, price: u32) -> Self {
pub fn new<S: Into<String>, P: Into<String>>(name: S, price: u32, picture_url: P) -> Self {
Self {
name: name.into(),
price,
picture_url: picture_url.into(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LocalService {
pub struct LocalBusiness {
pub id: i32,
pub owner_id: i32,
pub name: String,
@ -70,7 +72,7 @@ pub struct LocalService {
pub items: Vec<db::LocalBusinessItem>,
}
impl<'v> From<(db::LocalBusiness, &'v mut Vec<db::LocalBusinessItem>)> for LocalService {
impl<'v> From<(db::LocalBusiness, &'v mut Vec<db::LocalBusinessItem>)> for LocalBusiness {
fn from((service, items): (db::LocalBusiness, &'v mut Vec<db::LocalBusinessItem>)) -> Self {
Self {
id: service.id,

View File

@ -6,14 +6,17 @@ use actix_files::Files;
use actix_web::web::{Data, ServiceConfig};
use actix_web::*;
use askama::Template;
use futures_util::stream::StreamExt as _;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::*;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
services: Vec<view::LocalService>,
services: Vec<view::LocalBusiness>,
account: Option<db::Account>,
error: Option<String>,
page: Page,
@ -68,7 +71,8 @@ SELECT
local_business_id,
name,
price,
item_order
item_order,
picture_url
FROM local_business_items
ORDER BY item_order DESC
"#,
@ -88,7 +92,7 @@ ORDER BY item_order DESC
use crate::model::view::*;
services
.into_iter()
.map(|service| LocalService::from((service, &mut items)))
.map(|service| LocalBusiness::from((service, &mut items)))
.collect()
};
@ -160,7 +164,7 @@ fn process_items(items: &mut Vec<view::BusinessItemInput>, names: HashMap<String
.map(|s| s.strip_suffix(']').unwrap_or(s));
let idx: u16 = name.next().and_then(|s| s.parse().ok())?;
match name.next() {
Some(s @ ("name" | "price")) => Some((idx, s.to_string(), value)),
Some(s @ ("name" | "price" | "picture_url")) => Some((idx, s.to_string(), value)),
_ => None,
}
})
@ -177,6 +181,9 @@ fn process_items(items: &mut Vec<view::BusinessItemInput>, names: HashMap<String
"price" => {
item.price = value.parse().unwrap_or_default();
}
"picture_url" => {
item.picture_url = value;
}
_ => {}
};
memo
@ -304,15 +311,16 @@ RETURNING id, owner_id, name, description, state
for (idx, item) in form.items.unwrap_or_default().iter().enumerate() {
let res: sqlx::Result<db::LocalBusinessItem> = sqlx::query_as(
r#"
INSERT INTO local_business_items (local_business_id, name, price, item_order)
VALUES ($1, $2, $3, $4)
RETURNING id, local_business_id, name, price, item_order
INSERT INTO local_business_items (local_business_id, name, price, item_order, picture_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, local_business_id, name, price, item_order, picture_url
"#,
)
.bind(business.id)
.bind(&item.name)
.bind(item.price as i32)
.bind(idx as i32)
.bind(item.picture_url)
.fetch_one(&mut t)
.await;
match res {
@ -427,8 +435,47 @@ WHERE email = $1
)
}
#[derive(Serialize)]
struct UploadResponse {
path: String,
}
#[post("/upload")]
async fn upload(
mut payload: actix_multipart::Multipart,
id: Identity,
) -> Result<HttpResponse, actix_web::Error> {
let path = PathBuf::new().join(
id.identity()
.map(|id| format!("./uploads/{id}"))
.unwrap_or_else(|| "./uploads/tmp".into()),
);
std::fs::create_dir_all(&path)?;
if let Some(item) = payload.next().await {
let mut field = item?;
let name = field.name();
tracing::info!("Writing file {:?}", name);
let path = path.join(name);
while let Some(chunk) = field.next().await {
let chunk = chunk?;
std::fs::write(&path, chunk)?;
}
Ok(HttpResponse::Ok().json(UploadResponse {
path: path.to_str().unwrap_or_default().into(),
}))
} else {
Ok(HttpResponse::BadRequest().finish())
}
}
pub fn configure(config: &mut ServiceConfig) {
std::fs::create_dir_all("./uploads").expect("Failed to create ./uploads directory");
config
.service(Files::new("/uploads", "./uploads"))
.service(Files::new("/assets/images", "./assets/images"))
.service(Files::new("/assets/css", "./assets/css"))
.service(
@ -441,7 +488,8 @@ pub fn configure(config: &mut ServiceConfig) {
.service(account_page)
.service(register)
.service(logout)
.service(login);
.service(login)
.service(upload);
}
#[cfg(test)]