Compare commits
No commits in common. "9cac6c3c3e03c9da53142eee55a0891a21fd6933" and "47c2b74ecb83256b9aadb8c5fc3981d5b475bc67" have entirely different histories.
9cac6c3c3e
...
47c2b74ecb
13
Cargo.lock
generated
13
Cargo.lock
generated
|
@ -179,7 +179,6 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"axum-macros",
|
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
@ -227,17 +226,6 @@ 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"
|
||||||
|
@ -2143,7 +2131,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-macros",
|
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
%% title = "command line"
|
|
||||||
|
|
||||||
% id = "01JEK4XKK26T6W603FTPQHQ7C8"
|
|
||||||
- press `<kbd>:</kbd>`{=html} to open the command line.
|
|
||||||
|
|
||||||
% id = "01JEK4XKK27KXP01EK8K890SPK"
|
|
||||||
- type in your command, then press `<kbd>Enter</kbd>`{=html} to run it.
|
|
||||||
|
|
||||||
- `<kbd>Esc</kbd>`{=html} closes the command line.
|
|
||||||
|
|
||||||
- `<kbd>Tab</kbd>`{=html} cycles through suggestions.
|
|
||||||
|
|
||||||
- you may also use the mouse to close the command line or pick a suggestion from the list.
|
|
||||||
|
|
||||||
% id = "01JEK4XKK2EDTVCNZQRV9XDZXJ"
|
|
||||||
- unknown commands do not do anything.
|
|
||||||
known commands usually result in immediate feedback.
|
|
||||||
|
|
||||||
% id = "01JEK4XKK2S4W0TPT4JY8AH143"
|
|
||||||
- the command line is currently not accessible on mobile devices.
|
|
|
@ -1,40 +0,0 @@
|
||||||
%% title = "developer tools"
|
|
||||||
styles = ["dev.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
|
|
||||||
|
|
|
@ -8,8 +8,7 @@ edition = "2021"
|
||||||
treehouse-format = { workspace = true }
|
treehouse-format = { workspace = true }
|
||||||
|
|
||||||
anyhow = "1.0.75"
|
anyhow = "1.0.75"
|
||||||
axum = { version = "0.7.9", features = ["macros"] }
|
axum = "0.7.4"
|
||||||
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"] }
|
||||||
|
|
|
@ -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().into(),
|
treehouse.source(file_id).input().to_owned(),
|
||||||
),
|
),
|
||||||
Edit::Write(edit_path, fixed.into()),
|
Edit::Write(edit_path, fixed),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
Edit::Write(edit_path, fixed.into())
|
Edit::Write(edit_path, fixed)
|
||||||
}
|
}
|
||||||
} 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.into()));
|
return Ok(Edit::Write(edit_path, fixed));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
report_diagnostics(&treehouse, &diagnostics)?;
|
report_diagnostics(&treehouse, &diagnostics)?;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
#[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};
|
||||||
|
@ -19,7 +18,6 @@ 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;
|
||||||
|
@ -38,27 +36,17 @@ struct Server {
|
||||||
target: AsyncDir,
|
target: AsyncDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(sources, dirs, target))]
|
#[instrument(skip(sources, target))]
|
||||||
pub async fn serve(
|
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> {
|
||||||
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 {
|
.with_state(Arc::new(Server { sources, target }));
|
||||||
sources: sources.clone(),
|
|
||||||
target,
|
|
||||||
}));
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let app = app
|
let app = app.nest("/dev/live-reload", live_reload::router());
|
||||||
.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?;
|
||||||
|
|
|
@ -1,84 +0,0 @@
|
||||||
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(¶ms.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,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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.clone(), sources.clone());
|
let target = generate::target(dirs, sources.clone());
|
||||||
drop(_span);
|
drop(_span);
|
||||||
drop(flush_guard);
|
drop(flush_guard);
|
||||||
|
|
||||||
serve(sources, dirs, AsyncDir::new(target), serve_args.port).await?;
|
serve(sources, 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?,
|
||||||
|
|
|
@ -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 content to a file.
|
/// Write the given string to a file.
|
||||||
Write(EditPath, Vec<u8>),
|
Write(EditPath, String),
|
||||||
|
|
||||||
/// Execute a sequence of edits in order.
|
/// Execute a sequence of edits in order.
|
||||||
Seq(Vec<Edit>),
|
Seq(Vec<Edit>),
|
||||||
|
|
|
@ -1,49 +0,0 @@
|
||||||
/* Styles for developer tools.
|
|
||||||
This stylesheet MUST NOT be used for modifying the appearance of elements globally.
|
|
||||||
If you notice that it is for whatever reason, please bonk liquidex on the head. */
|
|
||||||
|
|
||||||
th-picture-upload {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
& > .nothing-pasted {
|
|
||||||
border: 1px solid var(--border-1);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -108,11 +108,6 @@ body {
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 1px solid var(--liquidex-brand-blue);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Set up typography */
|
/* Set up typography */
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
@ -135,12 +130,8 @@ body,
|
||||||
pre,
|
pre,
|
||||||
code,
|
code,
|
||||||
kbd,
|
kbd,
|
||||||
button,
|
button {
|
||||||
select,
|
|
||||||
input,
|
|
||||||
dfn {
|
|
||||||
font-family: "RecVar", sans-serif;
|
font-family: "RecVar", sans-serif;
|
||||||
font-style: normal;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,10 +142,8 @@ body {
|
||||||
pre,
|
pre,
|
||||||
code,
|
code,
|
||||||
kbd,
|
kbd,
|
||||||
button,
|
button {
|
||||||
select,
|
font-size: 100%;
|
||||||
input {
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
@ -508,7 +497,28 @@ h1.page-title {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style badges */
|
/* Style the `new` link on the homepage */
|
||||||
|
a[data-cast~="new"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 50%;
|
||||||
|
|
||||||
|
&.has-news {
|
||||||
|
opacity: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
& .new-text {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .badge {
|
||||||
|
margin-left: var(--8px);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style new badges */
|
||||||
span.badge {
|
span.badge {
|
||||||
--recursive-wght: 800;
|
--recursive-wght: 800;
|
||||||
--recursive-mono: 1;
|
--recursive-mono: 1;
|
||||||
|
@ -629,20 +639,6 @@ footer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style dialogues */
|
|
||||||
|
|
||||||
dialog[open] {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border: 1px solid var(--border-1);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style emojis to be readable */
|
/* Style emojis to be readable */
|
||||||
|
|
||||||
img[data-cast~="emoji"] {
|
img[data-cast~="emoji"] {
|
||||||
|
@ -704,78 +700,35 @@ th-emoji-tooltip p {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Command line */
|
/* Funny joke */
|
||||||
|
|
||||||
th-command-line {
|
@keyframes hello-there {
|
||||||
--recursive-mono: 1;
|
0% {
|
||||||
--recursive-casl: 0;
|
opacity: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
opacity: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oops-you-seem-to-have-gotten-stuck {
|
||||||
|
margin-top: 16px;
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
position: absolute;
|
||||||
|
opacity: 0%;
|
||||||
background-color: var(--background-color-tooltip);
|
|
||||||
font-size: 87.5%;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
display: flex;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .input-wrapper {
|
#index\:treehouse
|
||||||
display: flex;
|
> details:not([open])
|
||||||
flex-direction: row;
|
> summary
|
||||||
|
.oops-you-seem-to-have-gotten-stuck {
|
||||||
padding: 2px 4px;
|
display: inline;
|
||||||
width: 100%;
|
animation: 4s hello-there forwards;
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: ":";
|
|
||||||
padding-right: 2px;
|
|
||||||
opacity: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > input {
|
|
||||||
background: none;
|
|
||||||
color: var(--text-color);
|
|
||||||
border: none;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > ul.suggestions {
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
& > li {
|
|
||||||
padding: 2px 8px;
|
|
||||||
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
& > dfn {
|
|
||||||
--recursive-crsv: 0;
|
|
||||||
--recursive-wght: 700;
|
|
||||||
margin-right: 2ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.tabbed {
|
|
||||||
background-color: var(--liquidex-brand-blue);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Literate programming support */
|
/* Literate programming support */
|
||||||
|
|
|
@ -1,174 +0,0 @@
|
||||||
export class CommandLine extends HTMLElement {
|
|
||||||
static commands = new Map();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.suggestions = this.appendChild(document.createElement("ul"));
|
|
||||||
this.suggestions.classList.add("suggestions");
|
|
||||||
|
|
||||||
let inputWrapper = this.appendChild(document.createElement("div"));
|
|
||||||
inputWrapper.classList.add("input-wrapper");
|
|
||||||
this.input = inputWrapper.appendChild(document.createElement("input"));
|
|
||||||
this.input.type = "text";
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key == ":" && !this.visible) {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
this.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key == "Escape") {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("click", () => {
|
|
||||||
this.hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key == "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
this.hide();
|
|
||||||
this.runCommand(this.input.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key == "Tab") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (event.shiftKey) this.tabToPreviousSuggestion();
|
|
||||||
else this.tabToNextSuggestion();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input.addEventListener("input", () => {
|
|
||||||
this.updateSuggestions();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get visible() {
|
|
||||||
return this.classList.contains("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
show() {
|
|
||||||
this.classList.add("visible");
|
|
||||||
this.input.focus();
|
|
||||||
this.input.value = "";
|
|
||||||
this.updateSuggestions();
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
this.classList.remove("visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
tab(current, next) {
|
|
||||||
current?.classList?.remove("tabbed");
|
|
||||||
next.classList.add("tabbed");
|
|
||||||
|
|
||||||
this.input.value = next.name;
|
|
||||||
// NOTE: Do NOT update suggestions here.
|
|
||||||
// This would cause the tabbing to break.
|
|
||||||
}
|
|
||||||
|
|
||||||
tabToNextSuggestion() {
|
|
||||||
let current = this.suggestions.querySelector(".tabbed");
|
|
||||||
let next = current?.nextSibling ?? this.suggestions.childNodes[0];
|
|
||||||
this.tab(current, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
tabToPreviousSuggestion() {
|
|
||||||
let current = this.suggestions.querySelector(".tabbed");
|
|
||||||
let previous =
|
|
||||||
current?.previousSibling ??
|
|
||||||
this.suggestions.childNodes[this.suggestions.childNodes.length - 1];
|
|
||||||
this.tab(current, previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSuggestions() {
|
|
||||||
let search = parseCommand(this.input.value)?.command ?? "";
|
|
||||||
let suggestions = Array.from(CommandLine.commands.entries()).filter(
|
|
||||||
([name, def]) => !def.isAlias && fuzzyMatch(search, name),
|
|
||||||
);
|
|
||||||
suggestions.sort();
|
|
||||||
|
|
||||||
this.suggestions.replaceChildren();
|
|
||||||
for (let [name, def] of suggestions) {
|
|
||||||
let suggestion = this.suggestions.appendChild(document.createElement("li"));
|
|
||||||
let commandName = suggestion.appendChild(document.createElement("dfn"));
|
|
||||||
commandName.textContent = name;
|
|
||||||
let commandDescription = suggestion.appendChild(document.createElement("span"));
|
|
||||||
commandDescription.classList.add("description");
|
|
||||||
commandDescription.textContent = def.description;
|
|
||||||
|
|
||||||
suggestion.name = name;
|
|
||||||
suggestion.def = def;
|
|
||||||
|
|
||||||
suggestion.addEventListener("click", () => {
|
|
||||||
this.input.value = name;
|
|
||||||
this.updateSuggestions();
|
|
||||||
this.input.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runCommand(commandLine) {
|
|
||||||
let { command, args } = parseCommand(commandLine);
|
|
||||||
let commandDef = CommandLine.commands.get(command);
|
|
||||||
if (CommandLine.commands.has(command)) {
|
|
||||||
commandDef.run(args);
|
|
||||||
} else {
|
|
||||||
console.log(`unknown command`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static registerCommand({ aliases, description, run }) {
|
|
||||||
for (let i = 0; i < aliases.length; ++i) {
|
|
||||||
CommandLine.commands.set(aliases[i], {
|
|
||||||
isAlias: i != 0,
|
|
||||||
description,
|
|
||||||
run,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("th-command-line", CommandLine);
|
|
||||||
|
|
||||||
function parseCommand(commandLine) {
|
|
||||||
let result = /^([^ ]+) *(.*)$/.exec(commandLine);
|
|
||||||
if (result == null) return null;
|
|
||||||
|
|
||||||
let [_, command, args] = result;
|
|
||||||
return { command, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/
|
|
||||||
function fuzzyMatch(pattern, string) {
|
|
||||||
let iPattern = 0;
|
|
||||||
let iString = 0;
|
|
||||||
|
|
||||||
while (iPattern < pattern.length && iString < string.length) {
|
|
||||||
if (pattern.charAt(iPattern).toLowerCase() == string.charAt(iString).toLowerCase()) {
|
|
||||||
iPattern += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
iString += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return iPattern == pattern.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandLine.registerCommand({
|
|
||||||
aliases: ["help", "h"],
|
|
||||||
description: '"OwO, what is this?"',
|
|
||||||
run() {
|
|
||||||
window.location = `${TREEHOUSE_SITE}/treehouse/cmd`;
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,172 +0,0 @@
|
||||||
import { CommandLine } from "treehouse/command-line.js";
|
|
||||||
|
|
||||||
class PictureUpload extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
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" tabindex="0">
|
|
||||||
paste or drop an image here to make a picture out of it
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
this.querySelector(".nothing-pasted").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async gotoHavePicture(imageType, imageFile) {
|
|
||||||
this.setState("have-picture");
|
|
||||||
this.innerHTML = `
|
|
||||||
<form name="upload" 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 type="submit" name="upload">upload</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let uploadForm = this.querySelector("form[name='upload']");
|
|
||||||
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']");
|
|
||||||
|
|
||||||
fileSize.textContent = formatSizeSI(imageFile.size);
|
|
||||||
label.focus();
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadForm.addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TREEHOUSE_DEV) {
|
|
||||||
CommandLine.registerCommand({
|
|
||||||
aliases: ["addpic"],
|
|
||||||
description: "add a picture interactively and copy its ulid",
|
|
||||||
run() {
|
|
||||||
let dialog = document.body.appendChild(document.createElement("dialog"));
|
|
||||||
dialog.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key == "Escape") dialog.close();
|
|
||||||
});
|
|
||||||
dialog.addEventListener("close", () => {
|
|
||||||
dialog.remove();
|
|
||||||
});
|
|
||||||
dialog.appendChild(new PictureUpload());
|
|
||||||
dialog.show();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -28,7 +28,6 @@
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<th-emoji-tooltips></th-emoji-tooltips>
|
<th-emoji-tooltips></th-emoji-tooltips>
|
||||||
<th-command-line></th-command-line>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -19,32 +19,28 @@ clever to do while browser vendors figure that out, we'll just have to do a cach
|
||||||
|
|
||||||
{{#if dev}}
|
{{#if dev}}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import "treehouse/dev/live-reload.js";
|
import "treehouse/live-reload.js";
|
||||||
import "treehouse/dev/picture-upload.js";
|
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="{{ asset 'css/dev.css' }}">
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TREEHOUSE_DEV = {{ dev }};
|
|
||||||
const TREEHOUSE_SITE = `{{ config.site }}`;
|
const TREEHOUSE_SITE = `{{ config.site }}`;
|
||||||
|
|
||||||
{{!-- Yeah, this should probably be solved in a better way somehow.
|
{{!-- Yeah, this should probably be solved in a better way somehow.
|
||||||
For now this is used to allow literate-programming.js to refer to syntax files with the ?v attribute,
|
For now this is used to allow literate-programming.js to refer to syntax files with the ?cache attribute,
|
||||||
so that they don't need to be redownloaded every single time. --}}
|
so that they don't need to be redownloaded every single time. --}}
|
||||||
const TREEHOUSE_SYNTAX_URLS = {
|
const TREEHOUSE_SYNTAX_URLS = {
|
||||||
javascript: `{{{ asset 'syntax/javascript.json' }}}`,
|
javascript: `{{{ asset 'syntax/javascript.json' }}}`,
|
||||||
haku: `{{{ asset 'syntax/haku.json' }}}`,
|
haku: `{{{ asset 'syntax/haku.json' }}}`,
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="module" async>
|
<script type="module">
|
||||||
import "treehouse/spells.js";
|
import "treehouse/spells.js";
|
||||||
import "treehouse/ulid.js";
|
import "treehouse/ulid.js";
|
||||||
import "treehouse/usability.js";
|
import "treehouse/usability.js";
|
||||||
import "treehouse/settings.js";
|
import "treehouse/settings.js";
|
||||||
import "treehouse/tree.js";
|
import "treehouse/tree.js";
|
||||||
import "treehouse/emoji.js";
|
import "treehouse/emoji.js";
|
||||||
import "treehouse/command-line.js";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<meta property="og:site_name" content="{{ config.user.title }}">
|
<meta property="og:site_name" content="{{ config.user.title }}">
|
||||||
|
|
Loading…
Reference in a new issue