diff --git a/.gitignore b/.gitignore index b6d49d0..e10f094 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,19 @@ * !src/ +!src/errors/ +!src/errors/internal_server_error/ +!src/errors/not_found/ !src/page/ !src/page/cube/ !src/routes/ -!src/routes/_404/ !src/routes/index/ !.gitignore +!*.gif +!*.woff2 + !*.css !*.js !*.rs diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..438c219 --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=src.tar"); + + Command::new("tar") + .args(["-czf", "src.tar", "src"]) + .output() + .expect("Failed to create tar archive"); +} diff --git a/src/asset.rs b/src/asset.rs new file mode 100644 index 0000000..02ae66d --- /dev/null +++ b/src/asset.rs @@ -0,0 +1,94 @@ +use maud::{ + html, + Markup, + PreEscaped, + Render, +}; + +use crate::minify; + +pub fn extension_of(path: &str) -> Option<&str> { + path.rsplit_once(".").map(|(_base, extension)| extension) +} + +pub enum Js { + Shared(&'static str), + Owned(String), +} + +impl Render for Js { + fn render(&self) -> Markup { + match self { + Self::Shared(path) => { + html! { + script src=(format!("/assets/{}", minify::insert_min(path))) {} + } + }, + Self::Owned(content) => { + html! { + script { + (minify::js(content)) + } + } + }, + } + } +} + +pub mod js { + macro_rules! owned { + ($path:literal) => { + crate::asset::Js::Owned(::embed::string!($path)) + }; + } + + pub(crate) use owned; +} + +pub enum Css { + Shared(&'static str), + Owned(String), +} + +impl Render for Css { + fn render(&self) -> Markup { + match self { + Self::Shared(path) => { + html! { + link rel="stylesheet" type="text/css" href=(format!("/assets/{}", minify::insert_min(path))); + } + }, + Self::Owned(content) => { + html! { + style { + (minify::css(content)) + } + } + }, + } + } +} + +pub mod css { + macro_rules! owned { + ($path:literal) => { + crate::asset::Css::Owned(::embed::string!($path).to_string()) + }; + } + + pub(crate) use owned; +} + +pub struct File(pub &'static str); + +impl Render for File { + fn render(&self) -> Markup { + PreEscaped(self.to_string()) + } +} + +impl ToString for File { + fn to_string(&self) -> String { + format!("/assets/{}", self.0) + } +} diff --git a/src/errors/internal_server_error/mod.rs b/src/errors/internal_server_error/mod.rs new file mode 100644 index 0000000..d8e5e68 --- /dev/null +++ b/src/errors/internal_server_error/mod.rs @@ -0,0 +1,41 @@ +use std::array; + +use actix_web::{ + dev::ServiceResponse, + middleware::ErrorHandlerResponse, +}; +use maud::html; + +use crate::{ + asset, + page::cube, +}; + +pub fn handler( + response: ServiceResponse, +) -> actix_web::Result> { + let (request, response) = response.into_parts(); + + let response = response.set_body( + cube::create( + asset::Css::Shared("not-found.css"), + array::from_fn(|_| { + (html! { + div class="frame" { "error" } + div class="square black" {} + div class="square red" {} + div class="square red" {} + div class="square black" {} + }) + .clone() + }), + ) + .into_string(), + ); + + Ok(ErrorHandlerResponse::Response( + ServiceResponse::new(request, response) + .map_into_boxed_body() + .map_into_right_body(), + )) +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..dfc8e39 --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,16 @@ +mod internal_server_error; +mod not_found; + +use actix_web::{ + http::StatusCode, + middleware::ErrorHandlers, +}; + +pub fn handler() -> ErrorHandlers { + ErrorHandlers::new() + .handler(StatusCode::NOT_FOUND, not_found::handler) + .handler( + StatusCode::INTERNAL_SERVER_ERROR, + internal_server_error::handler, + ) +} diff --git a/src/errors/not_found/mod.rs b/src/errors/not_found/mod.rs new file mode 100644 index 0000000..195ed5f --- /dev/null +++ b/src/errors/not_found/mod.rs @@ -0,0 +1,41 @@ +use std::array; + +use actix_web::{ + dev::ServiceResponse, + middleware::ErrorHandlerResponse, +}; +use maud::html; + +use crate::{ + asset, + page::cube, +}; + +pub fn handler( + response: ServiceResponse, +) -> actix_web::Result> { + let (request, response) = response.into_parts(); + + let response = response.set_body( + cube::create( + asset::Css::Shared("not-found.css"), + array::from_fn(|_| { + (html! { + div class="frame" { "404" } + div class="square black" {} + div class="square magenta" {} + div class="square magenta" {} + div class="square black" {} + }) + .clone() + }), + ) + .into_string(), + ); + + Ok(ErrorHandlerResponse::Response( + ServiceResponse::new(request, response) + .map_into_boxed_body() + .map_into_right_body(), + )) +} diff --git a/src/errors/not_found/not-found.css b/src/errors/not_found/not-found.css new file mode 100644 index 0000000..92ab48d --- /dev/null +++ b/src/errors/not_found/not-found.css @@ -0,0 +1,33 @@ +.face { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); + box-shadow: 0 0 10px #AAAAAA; +} + +.square { + width: 100%; + height: 100%; +} + +.black { + background-color: black; +} + +.magenta { + background-color: magenta; +} + +.red { + background-color: red; +} + +.frame { + position: absolute; + + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + color: black; +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7b95138..474b86b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,14 @@ #![feature(lazy_cell)] +mod asset; +mod errors; +mod minify; mod page; -// mod routes; +mod routes; use actix_web::{ main as async_main, + middleware, App, HttpServer, }; @@ -31,12 +35,17 @@ async fn main() -> anyhow::Result<()> { .target(env_logger::Target::Stdout) .init(); - HttpServer::new(App::new) //|| App::new().route(routes::index)) - .bind(("0.0.0.0", args.port)) - .with_context(|| format!("Failed to bind to 0.0.0.0:{port}", port = args.port))? - .run() - .await - .with_context(|| "Failed to run HttpServer")?; + HttpServer::new(|| { + App::new() + .wrap(middleware::Logger::default()) + .wrap(errors::handler()) + .service(routes::handler()) + }) + .bind(("0.0.0.0", args.port)) + .with_context(|| format!("Failed to bind to 0.0.0.0:{}", args.port))? + .run() + .await + .with_context(|| "Failed to run HttpServer")?; Ok(()) } diff --git a/src/minify.rs b/src/minify.rs new file mode 100644 index 0000000..9d09a63 --- /dev/null +++ b/src/minify.rs @@ -0,0 +1,73 @@ +use std::{ + env::temp_dir, + fs::File, + hash::{ + BuildHasher, + RandomState, + }, + io::Write, +}; + +use minify_js::{ + Session, + TopLevelMode, +}; + +use crate::asset::extension_of; + +pub const MINIFIABLE: &[&str] = &[".js", ".css"]; + +pub fn is_minifiable(path: &str) -> bool { + MINIFIABLE.iter().any(|extension| path.ends_with(extension)) +} + +pub fn insert_min(path: &str) -> String { + match path.rsplit_once(".") { + Some((base, extension)) => format!("{base}.min.{extension}"), + None => format!("{path}.min"), + } +} + +pub fn generic(path: &str, content: &[u8]) -> Vec { + match extension_of(path) { + Some("js") => js(&String::from_utf8(content.to_vec()).unwrap()).into_bytes(), + Some("css") => css(&String::from_utf8(content.to_vec()).unwrap()).into_bytes(), + _ => content.to_vec(), + } +} + +pub fn js(content: &str) -> String { + let mut output = Vec::new(); + + minify_js::minify( + &Session::new(), + TopLevelMode::Module, + content.as_bytes(), + &mut output, + ) + .unwrap(); + + String::from_utf8(output) + .map_err(|error| { + let hash = RandomState::new() + .hash_one(error.clone().into_bytes()) + .to_string(); + + let path = temp_dir().join(hash); + + let mut file = File::create(&path).unwrap(); + file.write_all(&error.into_bytes()).unwrap(); + + format!( + "Failed to create a String from minified JavaScript code. The minified code has \ + been written to {}", + path.display() + ) + }) + .unwrap() +} + +pub fn css(content: &str) -> String { + // TODO + content.to_string() +} diff --git a/src/page/BaiJamjuree700.woff2 b/src/page/BaiJamjuree700.woff2 new file mode 100644 index 0000000..e14827b Binary files /dev/null and b/src/page/BaiJamjuree700.woff2 differ diff --git a/src/page/cube/cube.css b/src/page/cube/cube.css new file mode 100644 index 0000000..6bc620b --- /dev/null +++ b/src/page/cube/cube.css @@ -0,0 +1,108 @@ +@font-face { + font-family: "Bai Jamjuree"; + font-weight: 700; + src: url("/assets/BaiJamjuree700.woff2") format("woff2"); +} + +body, +html { + height: 100%; + margin: 0; +} + +html { + background-color: #000000; + font-family: "Bai Jamjuree", sans; + font-size: 450%; + + overscroll-behavior: none; +} + +@media screen and (max-width: 400px) { + html { + font-size: 200%; + } +} + +@media screen and (max-width: 800px) and (min-width: 400px) { + html { + font-size: 300%; + } +} + +a { + color: #000000; + text-decoration-line: none; +} + +.frame { + background-color: #FFFFFF; + + width: min-content; + + padding: 0 .3em; + border-radius: 1em; + + user-select: none; +} + +.scene { + height: 100%; + width: 100%; + perspective: 15em; + + display: flex; + + align-items: center; + justify-content: center; +} + +.cube { + height: 5em; + width: 5em; + + position: relative; + + transform: translateZ(-2.498em); + transform-style: preserve-3d; +} + +.face { + background-size: cover; + background-position: center; + + width: 5em; + height: 5em; + + display: flex; + + align-items: center; + justify-content: center; + + position: absolute; +} + +.front { + transform: rotateY(0deg) translateZ(2.498em); +} + +.top { + /* Guess what? Yeah, you guessed right. Safari can't render shit. */ + transform: rotateX(89.99999999999999deg) translateZ(2.498em); +} + +.back { + transform: rotateY(180deg) translateZ(2.498em); +} + +.bottom { + transform: rotateX(-89.99999999999999deg) translateZ(2.498em); +} + +.right { + transform: rotateY(89.99999999999999deg) translateZ(2.498em); +} + +.left { + transform: rotateY(-89.99999999999999deg) translateZ(2.498em); +} \ No newline at end of file diff --git a/src/page/cube/cube.js b/src/page/cube/cube.js new file mode 100644 index 0000000..45a6cf5 --- /dev/null +++ b/src/page/cube/cube.js @@ -0,0 +1,228 @@ +"use strict"; + +class Vec3 { + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + + static zero() { + return new Vec3(0, 0, 0); + } + + length() { + return Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2); + } + + scale(factor) { + this.x *= factor; + this.y *= factor; + this.z *= factor; + + return this; + } + + normalize() { + const length = this.length(); + + if (length != 0) { + this.x /= length; + this.y /= length; + this.z /= length; + } + + return this; + } + + static sub(v, t) { + return new Vec3( + v.x - t.x, + v.y - t.y, + v.z - t.z, + ) + } + + static sum(v, t) { + return new Vec3( + v.x + t.x, + v.y + t.y, + v.z + t.z, + ) + } +} + +class Quat { + constructor(x, y, z, w) { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + static fromAxis(axis) { + const angle = axis.length(); + + axis.normalize(); + + const half = angle / 2; + + const sinHalf = Math.sin(half); + const cosHalf = Math.cos(half); + + const x = axis.x * sinHalf; + const y = axis.y * sinHalf; + const z = axis.z * sinHalf; + const w = cosHalf; + + return new Quat(x, y, z, w); + } + + static mul(q, r) { + return new Quat( + q.w * r.x + q.x * r.w + q.y * r.z - q.z * r.y, + q.w * r.y - q.x * r.z + q.y * r.w + q.z * r.x, + q.w * r.z + q.x * r.y - q.y * r.x + q.z * r.w, + q.w * r.w - q.x * r.x - q.y * r.y - q.z * r.z, + ); + } +} + +let friction = 3; +let sensitivity = 0.01; +let velocity = Vec3.zero(); + +const orientation = { + __cube: document.querySelector(".cube"), + __value: new Quat(0, 0, 0, 1), + + set(value) { + this.__value = value; + + const q = this.__value; + this.__cube.style.transform = `rotate3d(${q.x}, ${q.y}, ${q.z}, ${Math.acos(q.w) * 2}rad)`; + }, + + get() { + return this.__value; + }, +}; + +{ + const mouse = { + down: false, + lastMove: -10000, + previous: null, + }; + + let impulseThisFrame = Vec3.zero(); + + const handleUp = () => { + mouse.down = false; + }; + + document.addEventListener("mouseup", handleUp); + document.addEventListener("touchend", handleUp); + + const handleDown = (event) => { + // Disables link dragging that occurs when spinning. + event.preventDefault(); + + mouse.down = true; + + velocity = Vec3.zero(); + }; + + document.addEventListener("mousedown", handleDown); + document.addEventListener("touchstart", handleDown); + + const handleMove = (event) => { + // Disables scrolling. + event.preventDefault(); + + if (!mouse.down) return; + + const newMouse = new Vec3(event.clientX, event.clientY, 0); + + const timeDelta = (window.performance.now() - mouse.lastMove) / 1000; + + if (timeDelta > 0.1) { + // This is a fresh scroll. + mouse.previous = newMouse; + } + + const delta = Vec3.sub(newMouse, mouse.previous); + + mouse.previous = newMouse; + mouse.lastMove = window.performance.now(); + + const axis = new Vec3(-delta.y, delta.x, 0) + .normalize() + .scale(delta.length() * sensitivity); + + impulseThisFrame = Vec3.sum(impulseThisFrame, axis); + + const rotation = Quat.fromAxis(axis); + + orientation.set(Quat.mul(rotation, orientation.get())); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("touchmove", (event) => { + const delta = event.changedTouches[0]; + + event.clientX = delta.clientX; + event.clientY = delta.clientY; + + handleMove(event); + }); + + let lastUpdate = 0; + + const updateFrame = (timestamp) => { + if (lastUpdate == 0) lastUpdate = timestamp; + + const delta = (timestamp - lastUpdate) / 1000; + lastUpdate = timestamp; + + if (mouse.down) { + velocity = impulseThisFrame.scale(1 / delta); + impulseThisFrame = Vec3.zero(); + } else { + const decay = Math.exp(-delta * friction); + + const effectiveDelta = friction > 0 ? (1 - decay) / friction : delta; + + let theta = effectiveDelta * velocity.length(); + + velocity.x *= decay; + velocity.y *= decay; + velocity.z *= decay; + + if (friction > 0 && velocity.length() < 0.00001) { + theta += velocity.length() / friction; + + velocity.x = 0; + velocity.y = 0; + velocity.z = 0; + } + + if (window.performance.now() - mouse.lastMove > 10000) { + const impulse = new Vec3(1, 1, -1); + velocity = Vec3.sum(impulse.scale(effectiveDelta * 3), velocity); + } + + const axis = new Vec3(velocity.x, velocity.y, velocity.z) + .normalize() + .scale(theta); + + const rotation = Quat.fromAxis(axis); + + orientation.set(Quat.mul(rotation, orientation.get())); + } + + requestAnimationFrame(updateFrame); + }; + + updateFrame(0); +} diff --git a/src/page/cube/mod.rs b/src/page/cube/mod.rs new file mode 100644 index 0000000..b1596d8 --- /dev/null +++ b/src/page/cube/mod.rs @@ -0,0 +1,35 @@ +use maud::{ + html, + Markup, +}; + +use crate::{ + asset::Css, + page::asset, +}; + +/// Creates a pure HTML CSS and JS cube with 6 faces, the +/// order of the faces are as so: +/// +/// front, top, back, bottom, right, left. +pub fn create(css: Css, faces: [Markup; 6]) -> Markup { + crate::page::create( + html! { + (asset::Css::Shared("cube.css")) + (css) + }, + html! { + div class="scene" { + div class="cube" { + @for (name, content) in ["front", "top", "back", "bottom", "right", "left"].iter().zip(faces) { + div class=(format!("face {name}")) { + (content) + } + } + } + } + + (asset::Js::Shared("cube.js")) + }, + ) +} diff --git a/src/page/elements.rs b/src/page/elements.rs new file mode 100644 index 0000000..8916d32 --- /dev/null +++ b/src/page/elements.rs @@ -0,0 +1,18 @@ +use maud::{ + html, + Markup, +}; + +/// Creates a meta tag with property and content. +pub fn property(name: &str, content: &str) -> Markup { + html! { + meta property=(name) content=(content); + } +} + +/// Creates a meta tag with name and content. +pub fn pname(name: &str, content: &str) -> Markup { + html! { + meta name=(name) content=(content); + } +} diff --git a/src/page/icon.gif b/src/page/icon.gif new file mode 100644 index 0000000..14b8c13 Binary files /dev/null and b/src/page/icon.gif differ diff --git a/src/page/mod.rs b/src/page/mod.rs index 73d7562..661d01e 100644 --- a/src/page/mod.rs +++ b/src/page/mod.rs @@ -1,42 +1,31 @@ +pub mod cube; +mod elements; + use std::sync::LazyLock; use anyhow::Context; use cargo_toml::Manifest; +pub use elements::*; use maud::{ html, Markup, DOCTYPE, }; +use crate::asset; + static MANIFEST: LazyLock = LazyLock::new(|| { Manifest::from_str(&embed::string!("../../Cargo.toml")) - .with_context(|| "Failed to deserialize Cargo manifest.") + .with_context(|| "Failed to deserialize Cargo manifest") .unwrap() }); -fn property(name: &str, content: &str) -> Markup { - html! { - meta property=(name) content=(content); - } -} - -fn pname(name: &str, content: &str) -> Markup { - html! { - meta name=(name) content=(content); - } -} - -/// Creates an asset URL from the given asset path. -pub(crate) fn asset(path: &str) -> String { - format!("/assets/{path}") -} - /// Creates a page with the given head and body. /// /// This is the most low level function for page creation /// as all pages use this, as this function provides the /// page title, OpenGraph and other information. -pub(crate) fn create(head: Markup, body: Markup) -> Markup { +pub fn create(head: Markup, body: Markup) -> Markup { html! { (DOCTYPE) @@ -58,9 +47,9 @@ pub(crate) fn create(head: Markup, body: Markup) -> Markup { (pname("description", description)) (property("og:description", description)) - link rel="icon" href=(asset("icon.gif")) type="image/gif"; + link rel="icon" href=(asset::File("icon.gif")) type="image/gif"; - (property("og:image", &asset("thumbnail.png"))) + (property("og:image", &asset::File("thumbnail.png").to_string())) (property("og:image:type", "image/png")) (property("og:image:height", "1080")) (property("og:image:width", "600")) diff --git a/src/routes/assets.rs b/src/routes/assets.rs new file mode 100644 index 0000000..31a87d5 --- /dev/null +++ b/src/routes/assets.rs @@ -0,0 +1,77 @@ +use std::{ + collections::HashMap, + io::{ + Cursor, + Read, + }, + sync::LazyLock, +}; + +use actix_web::{ + get, + web, + HttpResponse, +}; +use bytes::Bytes; +use tar::Archive; + +use crate::minify; + +const ASSET_EXTENSIONS: &[&str] = &[".js", ".css", ".woff2", ".gif"]; + +static ASSETS: LazyLock> = LazyLock::new(|| { + let contents = embed::bytes!("../../src.tar"); + let mut archive = Archive::new(Cursor::new(contents)); + + let mut assets = HashMap::new(); + + for entry in archive.entries().unwrap() { + let mut entry = if let Ok(entry) = entry { + entry + } else { + log::error!("fail"); + continue; + }; + + let path = entry.path_bytes(); + let path = String::from_utf8(path.to_vec()).unwrap(); + + if path.ends_with("/") || !ASSET_EXTENSIONS.iter().any(|ext| path.ends_with(ext)) { + continue; + } + + let path = path.rsplit_once("/").unwrap_or(("", &path)).1; + + let mut content = Vec::new(); + entry.read_to_end(&mut content).unwrap(); + + if minify::is_minifiable(&path) { + let content = minify::generic(&path, &content); + + log::info!("Minifying asset {path}"); + assets.insert(minify::insert_min(path), Bytes::from(content)); + } + + log::info!("Adding asset {path}"); + assets.insert(path.to_string(), Bytes::from(content)); + } + + assets +}); + +#[get("/assets/{name}")] +pub async fn handler(name: web::Path) -> HttpResponse { + let name = name.into_inner(); + + if let Some(body) = ASSETS.get(&name) { + HttpResponse::Ok() + .content_type( + mime_guess::from_path(&name) + .first_or_octet_stream() + .essence_str(), + ) + .body(Bytes::clone(body)) + } else { + HttpResponse::NotFound().into() + } +} diff --git a/src/routes/index/index.css b/src/routes/index/index.css new file mode 100644 index 0000000..e428d6d --- /dev/null +++ b/src/routes/index/index.css @@ -0,0 +1,68 @@ +.frame:hover { + background-color: #FFFF00; +} + +.face::after { + z-index: -1; + + content: ""; + + height: inherit; + width: inherit; + + position: absolute; +} + +.front { + background: linear-gradient(to bottom, cyan, blue); +} + +.front::after { + background: linear-gradient(to bottom, white, magenta); + mask-image: linear-gradient(to left, magenta, transparent); +} + +.top { + background: linear-gradient(to bottom, lime, cyan); +} + +.top::after { + background: linear-gradient(to bottom, yellow, white); + mask-image: linear-gradient(to left, white, transparent); +} + +.back { + background: linear-gradient(to bottom, yellow, red); +} + +.back::after { + background: linear-gradient(to bottom, lime, black); + mask-image: linear-gradient(to left, black, transparent); +} + +.bottom { + background: linear-gradient(to bottom, blue, black); +} + +.bottom::after { + background: linear-gradient(to bottom, magenta, red); + mask-image: linear-gradient(to left, red, transparent); +} + +.right { + background: linear-gradient(to bottom, white, magenta); +} + +.right::after { + background: linear-gradient(to bottom, yellow, red); + mask-image: linear-gradient(to left, red, transparent); +} + +.left { + background: linear-gradient(to bottom, lime, black); +} + +.left::after { + background: linear-gradient(to bottom, cyan, blue); + mask-image: linear-gradient(to left, blue, transparent); +} \ No newline at end of file diff --git a/src/routes/index/mod.rs b/src/routes/index/mod.rs new file mode 100644 index 0000000..4ecd9f1 --- /dev/null +++ b/src/routes/index/mod.rs @@ -0,0 +1,37 @@ +use actix_web::get; +use maud::{ + html, + Markup, +}; + +use crate::{ + asset, + page::cube, +}; + +#[get("/")] +pub async fn handler() -> actix_web::Result { + Ok(cube::create( + asset::css::owned!("index.css"), + [ + html! { + a href="/contact" { + div class="frame" { + "contact" + } + } + }, + html! { + a href="/github" { + div class="frame" { + "github" + } + } + }, + html! {}, + html! {}, + html! {}, + html! {}, + ], + )) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..158d4c5 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,13 @@ +mod assets; +mod index; + +use actix_web::{ + web, + Scope, +}; + +pub fn handler() -> Scope { + web::scope("") + .service(index::handler) + .service(assets::handler) +}