rich text editor with uploaded files
This commit is contained in:
parent
9564c37899
commit
4f9749949c
@ -17,7 +17,8 @@ customElements.define('article-form', class extends Component {
|
||||
<input placeholder="Tytuł" name="title" />
|
||||
</div>
|
||||
<section id="body-view">
|
||||
<rich-text-editor></rich-text-editor>
|
||||
<rich-text-editor upload-url="/admin/news/upload">
|
||||
</rich-text-editor>
|
||||
<input type="hidden" name="body" />
|
||||
</section>
|
||||
<section>
|
||||
|
@ -4,6 +4,10 @@ customElements.define('rich-text-editor', class extends Component {
|
||||
#selection;
|
||||
#range;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['upload-url'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(`
|
||||
<style>
|
||||
@ -198,34 +202,65 @@ customElements.define('rich-text-editor', class extends Component {
|
||||
this.#saveSelection();
|
||||
this.#wrapNode('ul', 'li');
|
||||
});
|
||||
const imgBtn = this.shadowRoot.querySelector('#image');
|
||||
const imgInput = imgBtn.querySelector('input');
|
||||
imgInput.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
imgInput.addEventListener('change', ev => {
|
||||
ev.stopPropagation();
|
||||
const file = imgInput.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const selected = this.#selected;
|
||||
if (!this.constructor.#isEditNode(selected)) return;
|
||||
let el = selected;
|
||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
||||
if (!el) return;
|
||||
const img = new Image();
|
||||
img.src = reader.result || '';
|
||||
el.appendChild(img);
|
||||
this.#emitChange();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
imgBtn.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.#saveSelection();
|
||||
imgInput.click();
|
||||
});
|
||||
{
|
||||
const imgBtn = this.shadowRoot.querySelector('#image');
|
||||
const imgInput = imgBtn.querySelector('input');
|
||||
imgInput.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
imgInput.addEventListener('change', ev => {
|
||||
ev.stopPropagation();
|
||||
const file = imgInput.files[0];
|
||||
const reader = new FileReader();
|
||||
let uploaded = false;
|
||||
const uuid = crypto.randomUUID();
|
||||
reader.onloadend = () => {
|
||||
let el = this.#targetElement;
|
||||
if (!el) return;
|
||||
const img = new Image();
|
||||
img.id = uuid;
|
||||
img.src = reader.result || '';
|
||||
if (uploaded) return;
|
||||
el.appendChild(img);
|
||||
this.#emitChange();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('loadend', (ev) => {
|
||||
uploaded = true;
|
||||
reader.abort();
|
||||
const response = JSON.parse(ev.target.response);
|
||||
{
|
||||
const img = this.shadowRoot.querySelector(`img[id="${ uuid }"]`);
|
||||
img && img.remove();
|
||||
}
|
||||
let el = this.#targetElement;
|
||||
if (!el) return;
|
||||
const img = new Image();
|
||||
img.id = uuid;
|
||||
img.src = response.path;
|
||||
el.appendChild(img);
|
||||
this.#emitChange();
|
||||
});
|
||||
xhr.open("POST", this.getAttribute('upload-url'));
|
||||
const f = new FormData;
|
||||
let name;
|
||||
if (file.name.includes('.')) {
|
||||
name = `${uuid}.${file.name.split('.').pop()}`;
|
||||
} else {
|
||||
name = uuid;
|
||||
}
|
||||
|
||||
f.append(name, file);
|
||||
xhr.send(f);
|
||||
});
|
||||
imgBtn.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.#saveSelection();
|
||||
imgInput.click();
|
||||
});
|
||||
}
|
||||
{
|
||||
const el = this.shadowRoot.querySelector("#align-justify");
|
||||
el.addEventListener('click', ev => {
|
||||
@ -334,9 +369,7 @@ customElements.define('rich-text-editor', class extends Component {
|
||||
|
||||
#wrapNode(...tags) {
|
||||
const selected = this.#selected;
|
||||
if (!this.constructor.#isEditNode(selected)) return;
|
||||
let el = selected;
|
||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
||||
let el = this.#targetElement;
|
||||
if (!el) return;
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
@ -367,10 +400,8 @@ customElements.define('rich-text-editor', class extends Component {
|
||||
}
|
||||
|
||||
#setStyle(setter, value) {
|
||||
const s = this.#selected;
|
||||
if (!this.constructor.#isEditNode(s)) return;
|
||||
let el = s;
|
||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
||||
let el = this.#targetElement;
|
||||
if (!el) return;
|
||||
if (el.id === 'edit') {
|
||||
const div = el.appendChild(document.createElement('div'));
|
||||
div.appendChild(s);
|
||||
@ -403,6 +434,16 @@ customElements.define('rich-text-editor', class extends Component {
|
||||
this.#emitChange();
|
||||
}
|
||||
|
||||
get #targetElement() {
|
||||
const selected = this.#selected;
|
||||
if (!this.constructor.#isEditNode(selected)) return;
|
||||
let el = selected;
|
||||
if (el.nodeType === Node.TEXT_NODE)
|
||||
el = el.parentElement;
|
||||
if (!el) return;
|
||||
return el;
|
||||
}
|
||||
|
||||
get #selected() {
|
||||
return this.#range.startContainer
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ use serde::Serializer;
|
||||
|
||||
mod restricted;
|
||||
mod unrestricted;
|
||||
pub mod uploads;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! ok_or_internal {
|
||||
|
@ -27,6 +27,16 @@ macro_rules! authorize {
|
||||
_ => return Err(crate::routes::Error::Unauthorized),
|
||||
}
|
||||
}};
|
||||
(json; $t: expr, $id: expr) => {{
|
||||
let account = match $id.identity() {
|
||||
None => return Err(crate::routes::Error::Unauthorized.to_json()),
|
||||
Some(id) => crate::queries::account_by_id($t, id).await,
|
||||
};
|
||||
match account {
|
||||
Some(account) => account,
|
||||
_ => return Err(crate::routes::Error::Unauthorized.to_json()),
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[get("/account/business-items")]
|
||||
@ -271,7 +281,7 @@ mod admin {
|
||||
use crate::model::view::Page;
|
||||
use crate::model::{db, view};
|
||||
use crate::queries;
|
||||
use crate::routes::{Identity, Result};
|
||||
use crate::routes::{Identity, JsonResult, Result};
|
||||
|
||||
macro_rules! require_admin {
|
||||
($t: expr, $id: expr) => {{
|
||||
@ -281,6 +291,13 @@ mod admin {
|
||||
}
|
||||
account
|
||||
}};
|
||||
(json; $t: expr, $id: expr) => {{
|
||||
let account = authorize!(json; &mut $t, $id);
|
||||
if account.account_type == crate::model::db::AccountType::Admin {
|
||||
return Err(crate::routes::Error::Forbidden.to_json());
|
||||
}
|
||||
account
|
||||
}};
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@ -357,10 +374,23 @@ mod admin {
|
||||
.finish())
|
||||
}
|
||||
|
||||
#[post("/upload")]
|
||||
async fn upload(
|
||||
payload: actix_multipart::Multipart,
|
||||
db: Data<PgPool>,
|
||||
id: Identity,
|
||||
) -> JsonResult<HttpResponse> {
|
||||
let pool = db.into_inner();
|
||||
let mut t = crate::ok_or_internal!(json pool.begin().await);
|
||||
let account = require_admin!(json; t, id);
|
||||
t.commit().await.ok();
|
||||
crate::routes::uploads::hande_upload(payload, Some(account.id), "news").await
|
||||
}
|
||||
|
||||
pub fn configure(config: &mut ServiceConfig) {
|
||||
config.service(
|
||||
web::scope("/admin")
|
||||
.service(web::scope("/news").service(create_news))
|
||||
.service(web::scope("/news").service(create_news).service(upload))
|
||||
.service(admin),
|
||||
);
|
||||
}
|
||||
|
@ -1,18 +1,16 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use tracing::*;
|
||||
|
||||
use crate::model::db;
|
||||
use crate::model::view::{self, Page};
|
||||
use crate::routes::{Error, Identity, JsonResult, Result};
|
||||
use crate::routes::{Identity, JsonResult, Result};
|
||||
use crate::{queries, utils};
|
||||
|
||||
#[derive(Template)]
|
||||
@ -324,7 +322,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
|
||||
format!("--{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
let name = item.picture_url.split('/').last().unwrap_or_default();
|
||||
let dir = item_picture_write_dir(format!("{}", account.id));
|
||||
let dir = utils::item_picture_write_dir(format!("{}", account.id));
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
error!("{e} {:?}", dir);
|
||||
dbg!(e);
|
||||
@ -475,18 +473,9 @@ async fn login(form: web::Form<LoginForm>, db: Data<PgPool>, id: Identity) -> Re
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UploadResponse {
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn item_picture_write_dir(account_id: String) -> PathBuf {
|
||||
PathBuf::new().join("./uploads").join(account_id)
|
||||
}
|
||||
|
||||
#[post("/upload")]
|
||||
async fn upload(
|
||||
mut payload: actix_multipart::Multipart,
|
||||
payload: actix_multipart::Multipart,
|
||||
db: Data<PgPool>,
|
||||
id: Identity,
|
||||
) -> JsonResult<HttpResponse> {
|
||||
@ -497,54 +486,7 @@ async fn upload(
|
||||
_ => None,
|
||||
};
|
||||
t.commit().await.ok();
|
||||
|
||||
let path = item_picture_write_dir(id.map(|id| format!("{id}")).unwrap_or_else(|| "tmp".into()));
|
||||
std::fs::create_dir_all(&path).map_err(|e| {
|
||||
error!("Cannot create upload directory {:?}", path);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
|
||||
if let Some(item) = payload.next().await {
|
||||
let mut field = item.map_err(|e| {
|
||||
warn!("Malformed upload file");
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
let name = field.name();
|
||||
info!("Writing file {:?}", name);
|
||||
let path = path.join(name);
|
||||
|
||||
while let Some(chunk) = field.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to read uploaded file bytes for {:?}/{:?}",
|
||||
path,
|
||||
field.name()
|
||||
);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
std::fs::write(&path, chunk).map_err(|e| {
|
||||
warn!(
|
||||
"Failed to write uploaded file bytes for {:?}/{:?}",
|
||||
path,
|
||||
field.name()
|
||||
);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(UploadResponse {
|
||||
path: String::from(path.to_str().unwrap_or_default())
|
||||
.strip_prefix('.')
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
}))
|
||||
} else {
|
||||
Ok(HttpResponse::BadRequest().finish())
|
||||
}
|
||||
crate::routes::uploads::hande_upload(payload, id, "accounts").await
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
86
src/routes/uploads.rs
Normal file
86
src/routes/uploads.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use actix_web::web::Buf;
|
||||
use actix_web::HttpResponse;
|
||||
// use futures_util::stream::StreamExt as _;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::routes::{Error, JsonResult};
|
||||
use crate::utils;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UploadResponse {
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub async fn hande_upload(
|
||||
mut payload: actix_multipart::Multipart,
|
||||
id: Option<i32>,
|
||||
ty: &str,
|
||||
) -> JsonResult<HttpResponse> {
|
||||
let path = utils::upload_dir(
|
||||
Path::new(ty).join(id.map(|id| format!("{id}")).unwrap_or_else(|| "tmp".into())),
|
||||
);
|
||||
std::fs::create_dir_all(&path).map_err(|e| {
|
||||
error!("Cannot create upload directory {:?}", path);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
|
||||
if let Some(item) = payload.next().await {
|
||||
let mut field = item.map_err(|e| {
|
||||
warn!("Malformed upload file");
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
let name = field.name();
|
||||
info!("Writing file {:?}", name);
|
||||
let path = path.join(name);
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to write uploaded file bytes for {:?}/{:?}",
|
||||
path,
|
||||
field.name()
|
||||
);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
while let Some(chunk) = field.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to read uploaded file bytes for {:?}/{:?}",
|
||||
path,
|
||||
field.name()
|
||||
);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
file.write(chunk.chunk()).map_err(|e| {
|
||||
warn!(
|
||||
"Failed to write uploaded file bytes for {:?}/{:?}",
|
||||
path,
|
||||
field.name()
|
||||
);
|
||||
dbg!(e);
|
||||
Error::UploadFailed.to_json()
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(UploadResponse {
|
||||
path: String::from(path.to_str().unwrap_or_default())
|
||||
.strip_prefix('.')
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
}))
|
||||
} else {
|
||||
Ok(HttpResponse::BadRequest().finish())
|
||||
}
|
||||
}
|
10
src/utils.rs
10
src/utils.rs
@ -1,3 +1,5 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
|
||||
@ -21,6 +23,14 @@ pub fn validate(pass: &str, pass_hash: &str) -> password_hash::Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upload_dir<P: AsRef<Path>>(ty: P) -> PathBuf {
|
||||
PathBuf::new().join("./uploads").join(ty)
|
||||
}
|
||||
|
||||
pub fn item_picture_write_dir<P: AsRef<Path>>(account_id: P) -> PathBuf {
|
||||
upload_dir(Path::new("accounts").join(account_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::utils::{encrypt, validate};
|
||||
|
Loading…
Reference in New Issue
Block a user