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" />
|
<input placeholder="Tytuł" name="title" />
|
||||||
</div>
|
</div>
|
||||||
<section id="body-view">
|
<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" />
|
<input type="hidden" name="body" />
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
@ -4,6 +4,10 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
#selection;
|
#selection;
|
||||||
#range;
|
#range;
|
||||||
|
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['upload-url'];
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(`
|
super(`
|
||||||
<style>
|
<style>
|
||||||
@ -198,6 +202,7 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
this.#saveSelection();
|
this.#saveSelection();
|
||||||
this.#wrapNode('ul', 'li');
|
this.#wrapNode('ul', 'li');
|
||||||
});
|
});
|
||||||
|
{
|
||||||
const imgBtn = this.shadowRoot.querySelector('#image');
|
const imgBtn = this.shadowRoot.querySelector('#image');
|
||||||
const imgInput = imgBtn.querySelector('input');
|
const imgInput = imgBtn.querySelector('input');
|
||||||
imgInput.addEventListener('click', ev => {
|
imgInput.addEventListener('click', ev => {
|
||||||
@ -207,18 +212,47 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const file = imgInput.files[0];
|
const file = imgInput.files[0];
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
let uploaded = false;
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
const selected = this.#selected;
|
let el = this.#targetElement;
|
||||||
if (!this.constructor.#isEditNode(selected)) return;
|
|
||||||
let el = selected;
|
|
||||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
img.id = uuid;
|
||||||
img.src = reader.result || '';
|
img.src = reader.result || '';
|
||||||
|
if (uploaded) return;
|
||||||
el.appendChild(img);
|
el.appendChild(img);
|
||||||
this.#emitChange();
|
this.#emitChange();
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
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 => {
|
imgBtn.addEventListener('click', ev => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@ -226,6 +260,7 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
this.#saveSelection();
|
this.#saveSelection();
|
||||||
imgInput.click();
|
imgInput.click();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const el = this.shadowRoot.querySelector("#align-justify");
|
const el = this.shadowRoot.querySelector("#align-justify");
|
||||||
el.addEventListener('click', ev => {
|
el.addEventListener('click', ev => {
|
||||||
@ -334,9 +369,7 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
|
|
||||||
#wrapNode(...tags) {
|
#wrapNode(...tags) {
|
||||||
const selected = this.#selected;
|
const selected = this.#selected;
|
||||||
if (!this.constructor.#isEditNode(selected)) return;
|
let el = this.#targetElement;
|
||||||
let el = selected;
|
|
||||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
for (let i = 0; i < tags.length; i++) {
|
for (let i = 0; i < tags.length; i++) {
|
||||||
@ -367,10 +400,8 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#setStyle(setter, value) {
|
#setStyle(setter, value) {
|
||||||
const s = this.#selected;
|
let el = this.#targetElement;
|
||||||
if (!this.constructor.#isEditNode(s)) return;
|
if (!el) return;
|
||||||
let el = s;
|
|
||||||
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
|
|
||||||
if (el.id === 'edit') {
|
if (el.id === 'edit') {
|
||||||
const div = el.appendChild(document.createElement('div'));
|
const div = el.appendChild(document.createElement('div'));
|
||||||
div.appendChild(s);
|
div.appendChild(s);
|
||||||
@ -403,6 +434,16 @@ customElements.define('rich-text-editor', class extends Component {
|
|||||||
this.#emitChange();
|
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() {
|
get #selected() {
|
||||||
return this.#range.startContainer
|
return this.#range.startContainer
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ use serde::Serializer;
|
|||||||
|
|
||||||
mod restricted;
|
mod restricted;
|
||||||
mod unrestricted;
|
mod unrestricted;
|
||||||
|
pub mod uploads;
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! ok_or_internal {
|
macro_rules! ok_or_internal {
|
||||||
|
@ -27,6 +27,16 @@ macro_rules! authorize {
|
|||||||
_ => return Err(crate::routes::Error::Unauthorized),
|
_ => 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")]
|
#[get("/account/business-items")]
|
||||||
@ -271,7 +281,7 @@ mod admin {
|
|||||||
use crate::model::view::Page;
|
use crate::model::view::Page;
|
||||||
use crate::model::{db, view};
|
use crate::model::{db, view};
|
||||||
use crate::queries;
|
use crate::queries;
|
||||||
use crate::routes::{Identity, Result};
|
use crate::routes::{Identity, JsonResult, Result};
|
||||||
|
|
||||||
macro_rules! require_admin {
|
macro_rules! require_admin {
|
||||||
($t: expr, $id: expr) => {{
|
($t: expr, $id: expr) => {{
|
||||||
@ -281,6 +291,13 @@ mod admin {
|
|||||||
}
|
}
|
||||||
account
|
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)]
|
#[derive(Debug, Template)]
|
||||||
@ -357,10 +374,23 @@ mod admin {
|
|||||||
.finish())
|
.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) {
|
pub fn configure(config: &mut ServiceConfig) {
|
||||||
config.service(
|
config.service(
|
||||||
web::scope("/admin")
|
web::scope("/admin")
|
||||||
.service(web::scope("/news").service(create_news))
|
.service(web::scope("/news").service(create_news).service(upload))
|
||||||
.service(admin),
|
.service(admin),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use actix_files::Files;
|
use actix_files::Files;
|
||||||
use actix_web::web::{Data, ServiceConfig};
|
use actix_web::web::{Data, ServiceConfig};
|
||||||
use actix_web::*;
|
use actix_web::*;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use futures_util::stream::StreamExt as _;
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tracing::*;
|
use tracing::*;
|
||||||
|
|
||||||
use crate::model::db;
|
use crate::model::db;
|
||||||
use crate::model::view::{self, Page};
|
use crate::model::view::{self, Page};
|
||||||
use crate::routes::{Error, Identity, JsonResult, Result};
|
use crate::routes::{Identity, JsonResult, Result};
|
||||||
use crate::{queries, utils};
|
use crate::{queries, utils};
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -324,7 +322,7 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
|
|||||||
format!("--{}", uuid::Uuid::new_v4())
|
format!("--{}", uuid::Uuid::new_v4())
|
||||||
} else {
|
} else {
|
||||||
let name = item.picture_url.split('/').last().unwrap_or_default();
|
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) {
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||||
error!("{e} {:?}", dir);
|
error!("{e} {:?}", dir);
|
||||||
dbg!(e);
|
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")]
|
#[post("/upload")]
|
||||||
async fn upload(
|
async fn upload(
|
||||||
mut payload: actix_multipart::Multipart,
|
payload: actix_multipart::Multipart,
|
||||||
db: Data<PgPool>,
|
db: Data<PgPool>,
|
||||||
id: Identity,
|
id: Identity,
|
||||||
) -> JsonResult<HttpResponse> {
|
) -> JsonResult<HttpResponse> {
|
||||||
@ -497,54 +486,7 @@ async fn upload(
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
t.commit().await.ok();
|
t.commit().await.ok();
|
||||||
|
crate::routes::uploads::hande_upload(payload, id, "accounts").await
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[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 argon2::{Algorithm, Argon2, Params, Version};
|
||||||
use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::utils::{encrypt, validate};
|
use crate::utils::{encrypt, validate};
|
||||||
|
Loading…
Reference in New Issue
Block a user