From 0ce7f502852764be2185abf965cd4061b563ff80 Mon Sep 17 00:00:00 2001 From: liquidev Date: Sun, 8 Dec 2024 12:45:29 +0100 Subject: [PATCH] add image upload feature in dev mode --- Cargo.lock | 13 ++ content/treehouse/dev/tools.tree | 40 +++++ crates/treehouse/Cargo.toml | 3 +- crates/treehouse/src/cli/fix.rs | 8 +- crates/treehouse/src/cli/serve.rs | 20 ++- .../treehouse/src/cli/serve/picture_upload.rs | 84 ++++++++++ crates/treehouse/src/main.rs | 4 +- crates/treehouse/src/vfs/edit.rs | 4 +- static/css/main.css | 6 +- static/css/page/treehouse/dev/tools.css | 53 +++++++ static/js/dev/picture-upload.js | 149 ++++++++++++++++++ 11 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 content/treehouse/dev/tools.tree create mode 100644 crates/treehouse/src/cli/serve/picture_upload.rs create mode 100644 static/css/page/treehouse/dev/tools.css create mode 100644 static/js/dev/picture-upload.js diff --git a/Cargo.lock b/Cargo.lock index 86dc7c6..e5e7ec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "bytes", "futures-util", "http", @@ -226,6 +227,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -2131,6 +2143,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-macros", "base64", "blake3", "chrono", diff --git a/content/treehouse/dev/tools.tree b/content/treehouse/dev/tools.tree new file mode 100644 index 0000000..c3624e5 --- /dev/null +++ b/content/treehouse/dev/tools.tree @@ -0,0 +1,40 @@ +%% title = "developer tools" +styles = ["page/treehouse/dev/tools.css"] +scripts = ["treehouse/dev/picture-upload.js"] + +% id = "01JEHDJSJP282VCTRKYHNFM4N7" +- welcome! if you stumbled upon this page at random, know these tools are available in *debug builds only* (which is not.) + + % id = "01JEHDJSJP7FT74RB92VRA14F2" + - I don't currently have an option to disable generating a page in release builds, so here you are. + +% id = "01JEHDJSJPPJP2HFY4DFW0G3Z0" +- #### picture upload + + % id = "01JEHDJSJP78Y3FFAVJ9EZPPAQ" + - ``` =html + + ``` + + % id = "01JEHF34KV8NMSHBWN6KJ2JGWT" + + label tags + + % id = "01JEHF34KVXJM3PW32CCTG9MJ9" + - `+pixel` - `image-rendering: pixelated; border-radius: 0;` + + % id = "01JEHF34KVCS6HPEPW30RCTE2Q" + - `+width72` + + % id = "01JEHF34KV1J9SWV2PRYJK1PEM" + - `+width160` + + % id = "01JEHF34KVEDE5YFXRZ544X1FH" + - `+width640` + + % id = "01JEHF34KVRMK1DVEM5QGV1FB3" + - `+width752` + + + compression levels + + - `lossless` - use for screenshots and other pictures that should not lose quality + diff --git a/crates/treehouse/Cargo.toml b/crates/treehouse/Cargo.toml index 509b003..df01863 100644 --- a/crates/treehouse/Cargo.toml +++ b/crates/treehouse/Cargo.toml @@ -8,7 +8,8 @@ edition = "2021" treehouse-format = { workspace = true } anyhow = "1.0.75" -axum = "0.7.4" +axum = { version = "0.7.9", features = ["macros"] } +axum-macros = "0.4.2" base64 = "0.21.7" blake3 = "1.5.3" chrono = { version = "0.4.35", features = ["serde"] } diff --git a/crates/treehouse/src/cli/fix.rs b/crates/treehouse/src/cli/fix.rs index c019754..e6db536 100644 --- a/crates/treehouse/src/cli/fix.rs +++ b/crates/treehouse/src/cli/fix.rs @@ -166,12 +166,12 @@ pub fn fix_file_cli(fix_args: FixArgs, root: &dyn Dir) -> anyhow::Result { Edit::Seq(vec![ Edit::Write( backup_edit_path, - treehouse.source(file_id).input().to_owned(), + treehouse.source(file_id).input().to_owned().into(), ), - Edit::Write(edit_path, fixed), + Edit::Write(edit_path, fixed.into()), ]) } else { - Edit::Write(edit_path, fixed) + Edit::Write(edit_path, fixed.into()) } } else { println!("{fixed}"); @@ -201,7 +201,7 @@ pub fn fix_all_cli(fix_all_args: FixAllArgs, dir: &dyn Dir) -> anyhow::Result, target: AsyncDir, port: u16) -> anyhow::Result<()> { +#[instrument(skip(sources, dirs, target))] +pub async fn serve( + sources: Arc, + dirs: Arc, + target: AsyncDir, + port: u16, +) -> anyhow::Result<()> { let app = Router::new() .route("/", get(index)) // needed explicitly because * does not match empty paths .route("/*path", get(vfs_entry)) .route("/b", get(branch)) .fallback(get(four_oh_four)) - .with_state(Arc::new(Server { sources, target })); + .with_state(Arc::new(Server { + sources: sources.clone(), + target, + })); #[cfg(debug_assertions)] - let app = app.nest("/dev/live-reload", live_reload::router()); + let app = app + .nest("/dev/live-reload", live_reload::router()) + .nest("/dev/picture-upload", picture_upload::router(dirs)); info!("serving on port {port}"); let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?; diff --git a/crates/treehouse/src/cli/serve/picture_upload.rs b/crates/treehouse/src/cli/serve/picture_upload.rs new file mode 100644 index 0000000..09b0591 --- /dev/null +++ b/crates/treehouse/src/cli/serve/picture_upload.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use axum::{ + body::Bytes, + debug_handler, + extract::{Query, State}, + response::IntoResponse, + routing::post, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + dirs::Dirs, + vfs::{self, Edit, EditPath, VPathBuf}, +}; + +pub fn router(dirs: Arc) -> Router { + Router::new() + .route("/", post(picture_upload)) + .with_state(dirs) +} + +#[derive(Deserialize)] +struct PictureUpload { + label: String, + format: String, + compression: Compression, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +enum Compression { + Lossless, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +enum Response { + Ulid(String), + Error(String), +} + +#[debug_handler] +async fn picture_upload( + State(dirs): State>, + Query(mut params): Query, + image: Bytes, +) -> impl IntoResponse { + let ulid = ulid::Generator::new() + .generate_with_source(&mut rand::thread_rng()) + .expect("failed to generate ulid"); + + if params.label.is_empty() { + params.label = "untitled".into(); + } + + let file_name = VPathBuf::new(format!( + "{ulid}-{}.{}", + params.label, + get_extension(¶ms.format).unwrap_or("unknown") + )); + let Some(edit_path) = vfs::query::(&dirs.pic, &file_name) else { + return Json(Response::Error(format!("{file_name} is not editable"))); + }; + + let result = match params.compression { + Compression::Lossless => Edit::Write(edit_path, image.to_vec()).apply().await, + }; + + Json(match result { + Ok(()) => Response::Ulid(ulid.to_string()), + Err(error) => Response::Error(error.to_string()), + }) +} + +fn get_extension(content_type: &str) -> Option<&'static str> { + match content_type { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/svg+xml" => Some("svg"), + "image/webp" => Some("webp"), + _ => None, + } +} diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs index 4be3df0..bfe9b7e 100644 --- a/crates/treehouse/src/main.rs +++ b/crates/treehouse/src/main.rs @@ -75,11 +75,11 @@ async fn fallible_main( } => { let _span = info_span!("load").entered(); let sources = Arc::new(Sources::load(&dirs).context("failed to load sources")?); - let target = generate::target(dirs, sources.clone()); + let target = generate::target(dirs.clone(), sources.clone()); drop(_span); drop(flush_guard); - serve(sources, AsyncDir::new(target), serve_args.port).await?; + serve(sources, dirs, AsyncDir::new(target), serve_args.port).await?; } Command::Fix(fix_args) => fix_file_cli(fix_args, &*dirs.content)?.apply().await?, diff --git a/crates/treehouse/src/vfs/edit.rs b/crates/treehouse/src/vfs/edit.rs index efe6b52..bc2fe0a 100644 --- a/crates/treehouse/src/vfs/edit.rs +++ b/crates/treehouse/src/vfs/edit.rs @@ -14,8 +14,8 @@ pub enum Edit { /// An edit that doesn't do anything. NoOp, - /// Write the given string to a file. - Write(EditPath, String), + /// Write the given content to a file. + Write(EditPath, Vec), /// Execute a sequence of edits in order. Seq(Vec), diff --git a/static/css/main.css b/static/css/main.css index 27c3b5e..648c50c 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -130,7 +130,8 @@ body, pre, code, kbd, -button { +button, +select { font-family: "RecVar", sans-serif; line-height: 1.5; } @@ -142,7 +143,8 @@ body { pre, code, kbd, -button { +button, +select { font-size: 100%; } diff --git a/static/css/page/treehouse/dev/tools.css b/static/css/page/treehouse/dev/tools.css new file mode 100644 index 0000000..8c70868 --- /dev/null +++ b/static/css/page/treehouse/dev/tools.css @@ -0,0 +1,53 @@ +th-picture-upload { + display: block; + + border: 1px solid var(--border-1); + padding: 8px 12px; + margin-right: 8px; + border-radius: 8px; + + cursor: default; + + &:focus { + border-color: var(--liquidex-brand-blue); + } + + & > .nothing-pasted { + text-align: center; + opacity: 50%; + padding: 16px; + } + + & > .have-picture { + & > p { + padding-bottom: 8px; + } + + & > img { + max-height: 480px; + } + } + + & > .copied-to-clipboard { + text-align: center; + padding: 16px; + } + + /* State display */ + + & > * { + display: none; + } + + &[data-state="init"] > .nothing-pasted { + display: block; + } + + &[data-state="have-picture"] > .have-picture { + display: block; + } + + &[data-state="copied-to-clipboard"] > .copied-to-clipboard { + display: block; + } +} diff --git a/static/js/dev/picture-upload.js b/static/js/dev/picture-upload.js new file mode 100644 index 0000000..179b65f --- /dev/null +++ b/static/js/dev/picture-upload.js @@ -0,0 +1,149 @@ +class PictureUpload extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + this.tabIndex = 0; + this.gotoInit(); + + this.preview = this.querySelector("img[name='preview']"); + + this.addEventListener("click", (event) => { + if (event.target == this || event.target.parentElement == this) { + event.preventDefault(); + this.focus(); + } + }); + + this.addEventListener("paste", async (event) => { + if (event.clipboardData.items.length != 1) { + console.error("only one item is supported"); + return; + } + + let item = event.clipboardData.items[0]; + await this.paste(item); + }); + } + + gotoInit() { + this.setState("init"); + this.innerHTML = ` +
+ paste or drop an image here to make a picture out of it +
+ `; + } + + async gotoHavePicture(imageType, imageFile) { + this.setState("have-picture"); + this.innerHTML = ` +
+ preview +

+ × px () +

+ +

+ + +

+ +

+ + +

+ + +
+ `; + + let preview = this.querySelector("img[name='preview']"); + let previewWidth = this.querySelector("[name='preview-width']"); + let previewHeight = this.querySelector("[name='preview-height']"); + let fileSize = this.querySelector("[name='file-size']"); + let label = this.querySelector("[name='label']"); + let compression = this.querySelector("[name='compression']"); + let upload = this.querySelector("button[name='upload']"); + + fileSize.textContent = formatSizeSI(imageFile.size); + + let url = URL.createObjectURL(imageFile); + preview.src = url; + + createImageBitmap(imageFile).then((bitmap) => { + console.log(bitmap); + previewWidth.textContent = bitmap.width.toString(); + previewHeight.textContent = bitmap.height.toString(); + }); + + upload.addEventListener("click", async () => { + let params = new URLSearchParams({ + label: label.value, + format: imageType, + compression: compression.value, + }); + let response = await fetch(`/dev/picture-upload?${params}`, { + method: "POST", + body: imageFile, + }); + let json = await response.json(); + if (json.error != null) { + console.error(json.error); + } else { + await navigator.clipboard.writeText(json.ulid); + await this.gotoCopiedToClipboard(); + } + }); + } + + async gotoCopiedToClipboard() { + this.setState("copied-to-clipboard"); + this.innerHTML = ` +
ulid copied to clipboard; the window will now refresh
+ `; + } + + setState(name) { + this.setAttribute("data-state", name); + } + + async paste(item) { + console.log(item); + if (!isSupportedImageType(item.type)) { + console.error("unsupported mime type", item.type); + return; + } + + let file = item.getAsFile(); + if (file == null) { + console.error("data transfer does not contain a file"); + return; + } + + await this.gotoHavePicture(item.type, file); + } +} + +customElements.define("th-picture-upload", PictureUpload); + +function isSupportedImageType(mime) { + return ( + mime == "image/png" || + mime == "image/jpeg" || + mime == "image/svg+xml" || + mime == "image/webp" + ); +} + +function formatSizeSI(bytes) { + return new Intl.NumberFormat(undefined, { + style: "unit", + unit: "byte", + notation: "compact", + unitDisplay: "narrow", + }).format(bytes); +}