Compare commits

...

2 commits

Author SHA1 Message Date
liquidex 9cac6c3c3e add a vim-like command line under : 2024-12-08 22:33:25 +01:00
liquidex 0ce7f50285 add image upload feature in dev mode 2024-12-08 12:45:44 +01:00
16 changed files with 683 additions and 66 deletions

13
Cargo.lock generated
View file

@ -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",

View file

@ -0,0 +1,20 @@
%% 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.

View file

@ -0,0 +1,40 @@
%% 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

View file

@ -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"] }

View file

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

View file

@ -1,5 +1,6 @@
#[cfg(debug_assertions)]
mod live_reload;
mod picture_upload;
use std::fmt::Write;
use std::{net::Ipv4Addr, sync::Arc};
@ -18,6 +19,7 @@ use serde::Deserialize;
use tokio::net::TcpListener;
use tracing::{info, instrument};
use crate::dirs::Dirs;
use crate::sources::Sources;
use crate::vfs::asynch::AsyncDir;
use crate::vfs::VPath;
@ -36,17 +38,27 @@ struct Server {
target: AsyncDir,
}
#[instrument(skip(sources, target))]
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> {
#[instrument(skip(sources, dirs, target))]
pub async fn serve(
sources: Arc<Sources>,
dirs: Arc<Dirs>,
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?;

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 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?,

View file

@ -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<u8>),
/// Execute a sequence of edits in order.
Seq(Vec<Edit>),

49
static/css/dev.css Normal file
View file

@ -0,0 +1,49 @@
/* 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;
}
}

View file

@ -108,6 +108,11 @@ body {
scrollbar-gutter: stable;
}
:focus-visible {
outline: 1px solid var(--liquidex-brand-blue);
outline-offset: 2px;
}
/* Set up typography */
@font-face {
@ -130,8 +135,12 @@ body,
pre,
code,
kbd,
button {
button,
select,
input,
dfn {
font-family: "RecVar", sans-serif;
font-style: normal;
line-height: 1.5;
}
@ -142,8 +151,10 @@ body {
pre,
code,
kbd,
button {
font-size: 100%;
button,
select,
input {
font-size: inherit;
}
:root {
@ -497,28 +508,7 @@ h1.page-title {
}
}
/* 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 */
/* Style badges */
span.badge {
--recursive-wght: 800;
--recursive-mono: 1;
@ -639,6 +629,20 @@ 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 */
img[data-cast~="emoji"] {
@ -700,35 +704,78 @@ th-emoji-tooltip p {
cursor: help;
}
/* Funny joke */
/* Command line */
@keyframes hello-there {
0% {
opacity: 0%;
}
th-command-line {
--recursive-mono: 1;
--recursive-casl: 0;
70% {
opacity: 0%;
}
100% {
opacity: 70%;
}
}
.oops-you-seem-to-have-gotten-stuck {
margin-top: 16px;
display: none;
position: absolute;
opacity: 0%;
}
flex-direction: column;
#index\:treehouse
> details:not([open])
> summary
.oops-you-seem-to-have-gotten-stuck {
display: inline;
animation: 4s hello-there forwards;
background-color: var(--background-color-tooltip);
font-size: 87.5%;
&.visible {
display: flex;
position: fixed;
left: 0;
bottom: 0;
width: 100%;
}
& > .input-wrapper {
display: flex;
flex-direction: row;
padding: 2px 4px;
width: 100%;
&::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 */

174
static/js/command-line.js Normal file
View file

@ -0,0 +1,174 @@
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`;
},
});

View file

@ -0,0 +1,172 @@
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();
},
});
}

View file

@ -28,6 +28,7 @@
{{/if}}
<th-emoji-tooltips></th-emoji-tooltips>
<th-command-line></th-command-line>
</body>
</html>

View file

@ -19,28 +19,32 @@ clever to do while browser vendors figure that out, we'll just have to do a cach
{{#if dev}}
<script type="module">
import "treehouse/live-reload.js";
import "treehouse/dev/live-reload.js";
import "treehouse/dev/picture-upload.js";
</script>
<link rel="stylesheet" href="{{ asset 'css/dev.css' }}">
{{/if}}
<script>
const TREEHOUSE_DEV = {{ dev }};
const TREEHOUSE_SITE = `{{ config.site }}`;
{{!-- 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 ?cache attribute,
For now this is used to allow literate-programming.js to refer to syntax files with the ?v attribute,
so that they don't need to be redownloaded every single time. --}}
const TREEHOUSE_SYNTAX_URLS = {
javascript: `{{{ asset 'syntax/javascript.json' }}}`,
haku: `{{{ asset 'syntax/haku.json' }}}`,
};
</script>
<script type="module">
<script type="module" async>
import "treehouse/spells.js";
import "treehouse/ulid.js";
import "treehouse/usability.js";
import "treehouse/settings.js";
import "treehouse/tree.js";
import "treehouse/emoji.js";
import "treehouse/command-line.js";
</script>
<meta property="og:site_name" content="{{ config.user.title }}">