add image upload feature in dev mode

This commit is contained in:
liquidex 2024-12-08 12:45:29 +01:00
parent 47c2b74ecb
commit 0ce7f50285
11 changed files with 369 additions and 15 deletions

13
Cargo.lock generated
View file

@ -179,6 +179,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"axum-macros",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@ -226,6 +227,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.74"
@ -2131,6 +2143,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"axum-macros",
"base64", "base64",
"blake3", "blake3",
"chrono", "chrono",

View file

@ -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 <https://liquidex.house> 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
<th-picture-upload></th-picture-upload>
```
% 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

View file

@ -8,7 +8,8 @@ edition = "2021"
treehouse-format = { workspace = true } treehouse-format = { workspace = true }
anyhow = "1.0.75" anyhow = "1.0.75"
axum = "0.7.4" axum = { version = "0.7.9", features = ["macros"] }
axum-macros = "0.4.2"
base64 = "0.21.7" base64 = "0.21.7"
blake3 = "1.5.3" blake3 = "1.5.3"
chrono = { version = "0.4.35", features = ["serde"] } chrono = { version = "0.4.35", features = ["serde"] }

View file

@ -166,12 +166,12 @@ pub fn fix_file_cli(fix_args: FixArgs, root: &dyn Dir) -> anyhow::Result<Edit> {
Edit::Seq(vec![ Edit::Seq(vec![
Edit::Write( Edit::Write(
backup_edit_path, 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 { } else {
Edit::Write(edit_path, fixed) Edit::Write(edit_path, fixed.into())
} }
} else { } else {
println!("{fixed}"); println!("{fixed}");
@ -201,7 +201,7 @@ pub fn fix_all_cli(fix_all_args: FixAllArgs, dir: &dyn Dir) -> anyhow::Result<Ed
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) { if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
if fixed != treehouse.source(file_id).input() { if fixed != treehouse.source(file_id).input() {
return Ok(Edit::Write(edit_path, fixed)); return Ok(Edit::Write(edit_path, fixed.into()));
} }
} else { } else {
report_diagnostics(&treehouse, &diagnostics)?; report_diagnostics(&treehouse, &diagnostics)?;

View file

@ -1,5 +1,6 @@
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod live_reload; mod live_reload;
mod picture_upload;
use std::fmt::Write; use std::fmt::Write;
use std::{net::Ipv4Addr, sync::Arc}; use std::{net::Ipv4Addr, sync::Arc};
@ -18,6 +19,7 @@ use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::{info, instrument}; use tracing::{info, instrument};
use crate::dirs::Dirs;
use crate::sources::Sources; use crate::sources::Sources;
use crate::vfs::asynch::AsyncDir; use crate::vfs::asynch::AsyncDir;
use crate::vfs::VPath; use crate::vfs::VPath;
@ -36,17 +38,27 @@ struct Server {
target: AsyncDir, target: AsyncDir,
} }
#[instrument(skip(sources, target))] #[instrument(skip(sources, dirs, target))]
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> { pub async fn serve(
sources: Arc<Sources>,
dirs: Arc<Dirs>,
target: AsyncDir,
port: u16,
) -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.route("/", get(index)) // needed explicitly because * does not match empty paths .route("/", get(index)) // needed explicitly because * does not match empty paths
.route("/*path", get(vfs_entry)) .route("/*path", get(vfs_entry))
.route("/b", get(branch)) .route("/b", get(branch))
.fallback(get(four_oh_four)) .fallback(get(four_oh_four))
.with_state(Arc::new(Server { sources, target })); .with_state(Arc::new(Server {
sources: sources.clone(),
target,
}));
#[cfg(debug_assertions)] #[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}"); info!("serving on port {port}");
let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?; let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?;

View file

@ -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<S>(dirs: Arc<Dirs>) -> Router<S> {
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<Arc<Dirs>>,
Query(mut params): Query<PictureUpload>,
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(&params.format).unwrap_or("unknown")
));
let Some(edit_path) = vfs::query::<EditPath>(&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,
}
}

View file

@ -75,11 +75,11 @@ async fn fallible_main(
} => { } => {
let _span = info_span!("load").entered(); let _span = info_span!("load").entered();
let sources = Arc::new(Sources::load(&dirs).context("failed to load sources")?); 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(_span);
drop(flush_guard); 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?, Command::Fix(fix_args) => fix_file_cli(fix_args, &*dirs.content)?.apply().await?,

View file

@ -14,8 +14,8 @@ pub enum Edit {
/// An edit that doesn't do anything. /// An edit that doesn't do anything.
NoOp, NoOp,
/// Write the given string to a file. /// Write the given content to a file.
Write(EditPath, String), Write(EditPath, Vec<u8>),
/// Execute a sequence of edits in order. /// Execute a sequence of edits in order.
Seq(Vec<Edit>), Seq(Vec<Edit>),

View file

@ -130,7 +130,8 @@ body,
pre, pre,
code, code,
kbd, kbd,
button { button,
select {
font-family: "RecVar", sans-serif; font-family: "RecVar", sans-serif;
line-height: 1.5; line-height: 1.5;
} }
@ -142,7 +143,8 @@ body {
pre, pre,
code, code,
kbd, kbd,
button { button,
select {
font-size: 100%; font-size: 100%;
} }

View file

@ -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;
}
}

View file

@ -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 = `
<div class="nothing-pasted">
paste or drop an image here to make a picture out of it
</div>
`;
}
async gotoHavePicture(imageType, imageFile) {
this.setState("have-picture");
this.innerHTML = `
<div class="have-picture">
<img name="preview" class="pic" alt="preview">
<p>
<span name="preview-width"></span> × <span name="preview-height"></span> px (<span name="file-size"></span>)
</p>
<p>
<label for="label">label</label>
<input name="label" type="text" placeholder="untitled"></input>
</p>
<p>
<label for="compression">compression</label>
<select name="compression">
<option value="Lossless">lossless</option>
</select>
</p>
<button name="upload">upload</button>
</div>
`;
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 = `
<div class="copied-to-clipboard">ulid copied to clipboard; the window will now refresh</div>
`;
}
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);
}