summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Hafskjold Thoresen <martin@vind.ai>2025-01-11 01:01:10 +0100
committerMartin Hafskjold Thoresen <martin@vind.ai>2025-01-11 01:01:10 +0100
commit42fd8cd3560047c88a2167a4d5c5845dd81eb3ec (patch)
treecdbd3817c28ae5264f8cf394684014f7bd4d509f
parent2f2beb79720ec9fd35b993e7ad907ecf32005cde (diff)
downloadmusicgame-42fd8cd3560047c88a2167a4d5c5845dd81eb3ec.tar.gz
musicgame-42fd8cd3560047c88a2167a4d5c5845dd81eb3ec.zip
Add styling and hot reload
-rw-r--r--Cargo.lock163
-rw-r--r--Cargo.toml1
-rw-r--r--src/hot_reload.js81
-rw-r--r--src/hot_reload.rs109
-rw-r--r--src/main.rs76
-rw-r--r--static/main.js61
-rw-r--r--static/style.css160
7 files changed, 623 insertions, 28 deletions
diff --git a/Cargo.lock b/Cargo.lock
index acb20d4..c4ba643 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -173,9 +173,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
-version = "2.6.0"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
[[package]]
name = "block-buffer"
@@ -260,6 +266,18 @@ dependencies = [
]
[[package]]
+name = "filetime"
+version = "0.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -275,6 +293,15 @@ dependencies = [
]
[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -430,12 +457,52 @@ dependencies = [
]
[[package]]
+name = "inotify"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
+dependencies = [
+ "bitflags 2.7.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "itoa"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
+name = "kqueue"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -448,6 +515,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
+name = "libredox"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
+dependencies = [
+ "bitflags 2.7.0",
+ "libc",
+ "redox_syscall",
+]
+
+[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -521,8 +599,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
+ "log",
"wasi",
- "windows-sys",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -536,6 +615,7 @@ dependencies = [
"bytes",
"cookie",
"maud",
+ "notify",
"rand",
"serde",
"serde_json",
@@ -547,6 +627,31 @@ dependencies = [
]
[[package]]
+name = "notify"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943"
+dependencies = [
+ "bitflags 2.7.0",
+ "filetime",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
+
+[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -688,6 +793,15 @@ dependencies = [
]
[[package]]
+name = "redox_syscall"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
+dependencies = [
+ "bitflags 2.7.0",
+]
+
+[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -706,6 +820,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
name = "serde"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -810,7 +933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -905,7 +1028,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -966,7 +1089,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
dependencies = [
- "bitflags",
+ "bitflags 2.7.0",
"bytes",
"futures-util",
"http",
@@ -1111,6 +1234,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1133,6 +1266,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
+name = "winapi-util"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1148,6 +1290,15 @@ dependencies = [
]
[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 3a87671..108527e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ axum-macros = "0.5.0"
bytes = "1.9.0"
cookie = { version = "0.18.1", features = ["percent-encode"] }
maud = { version = "0.26.0", features = ["axum"] }
+notify = "8.0.0"
rand = "0.8.5"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.134"
diff --git a/src/hot_reload.js b/src/hot_reload.js
new file mode 100644
index 0000000..0777380
--- /dev/null
+++ b/src/hot_reload.js
@@ -0,0 +1,81 @@
+const WS_RETRY_MS = 1_000;
+
+function refetchCSS(file) {
+ const node = Array.from(document.head.childNodes).find(
+ (n) => n.href && n.href.includes(file),
+ );
+ if (!node) {
+ console.warn("Could not find node", file);
+ console.log(Array.from(document.head.childNodes));
+ return;
+ }
+ const url = new URL(node.href);
+ url.searchParams.set("v", parseInt(url.searchParams.get("v") || "0") + 1);
+
+ const n2 = node.cloneNode();
+ n2.href = url.toString();
+ document.head.appendChild(n2);
+ setTimeout(() => {
+ document.head.removeChild(node);
+ }, 100);
+}
+
+let __file_to_module = {};
+function refetchJS(file) {
+ if (file in __file_to_module) {
+ const o = __file_to_module[file];
+ if ("__cleanup" in o) {
+ o.__cleanup();
+ } else {
+ console.warn("no cleanup");
+ }
+ }
+ import(`/static/${file}?v=${Date.now()}`)
+ .then((m) => {
+ __file_to_module[file] = m;
+ })
+ .catch((e) => {
+ console.error(e);
+ });
+ return;
+}
+
+function connect() {
+ console.log("try to connect through websocket");
+ const ws = new WebSocket(`ws://${window.location.host}/dev-hr/ws`);
+
+ const file2timeout = new Map();
+ ws.addEventListener("message", (msg) => {
+ const file = msg.data; // the path to a changed file relative to `static/`.
+ if (file2timeout.has(file)) clearTimeout(file2timeout.get(file));
+ file2timeout.set(
+ file,
+ setTimeout(() => {
+ if (file.slice(-4) === ".css") refetchCSS(file);
+ else if (file.slice(-3) === ".js") refetchJS(file);
+ else console.warn("unknown file extension", file);
+ }, 100),
+ );
+ });
+
+ ws.addEventListener("open", () => {
+ console.log("websocket connected");
+ });
+
+ ws.addEventListener("close", () => {
+ console.log("websocket disconnected");
+ setTimeout(() => {
+ try {
+ connect();
+ } catch (e) {
+ console.error(e);
+ }
+ }, WS_RETRY_MS);
+ });
+
+ ws.addEventListener("error", (e) => {
+ console.error(e);
+ ws.close();
+ });
+}
+setTimeout(() => connect(), WS_RETRY_MS);
diff --git a/src/hot_reload.rs b/src/hot_reload.rs
new file mode 100644
index 0000000..88cfc21
--- /dev/null
+++ b/src/hot_reload.rs
@@ -0,0 +1,109 @@
+use std::{
+ env,
+ net::SocketAddr,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use anyhow::Context;
+use axum::{
+ extract::{
+ ws::{Message, WebSocket},
+ ConnectInfo, State, WebSocketUpgrade,
+ },
+ response::IntoResponse,
+ routing, Router,
+};
+use bytes::Bytes;
+use notify::{Event, FsEventWatcher, Watcher};
+use tracing::trace;
+
+pub struct HotReload {
+ watcher: FsEventWatcher,
+
+ tx: tokio::sync::broadcast::Sender<String>,
+ rx: tokio::sync::broadcast::Receiver<String>,
+}
+
+impl HotReload {
+ pub fn new(base_path: impl AsRef<Path>) -> Arc<Self> {
+ Arc::new_cyclic(|hr| {
+ let weak = hr.clone();
+ let static_dir = PathBuf::from(base_path.as_ref())
+ .canonicalize()
+ .context("canonicalize static_dir")
+ .unwrap();
+
+ let (_tx, rx) = tokio::sync::broadcast::channel(16);
+
+ let tx = _tx.clone();
+
+ let mut watcher = notify::recommended_watcher(
+ move |res: std::result::Result<Event, notify::Error>| {
+ let Some(hot) = weak.upgrade() else {
+ return;
+ };
+ match res {
+ Ok(event) => {
+ for path in &event.paths {
+ let Ok(p) = path.strip_prefix(&static_dir) else {
+ continue;
+ };
+ if p.extension().is_some_and(|o| o == "css" || o == "js") {
+ let s = p.file_name().unwrap().to_string_lossy().to_string();
+ tx.send(s).expect("Failed to send to channel");
+ }
+ }
+ }
+ Err(e) => println!("watch error: {:?}", e),
+ }
+ },
+ )
+ .context("create watcher")
+ .unwrap();
+
+ watcher
+ .watch(std::path::Path::new("."), notify::RecursiveMode::Recursive)
+ .context("watcher.watch")
+ .unwrap();
+
+ HotReload {
+ watcher,
+ tx: _tx,
+ rx,
+ }
+ })
+ }
+}
+
+pub fn router(watch_dir: impl AsRef<Path>) -> Router<()> {
+ let hrl = HotReload::new(watch_dir);
+ Router::new()
+ .route("/ws", routing::get(handler_ws))
+ .with_state(hrl)
+}
+
+pub async fn handler_ws(
+ ws: WebSocketUpgrade,
+ State(st): State<Arc<HotReload>>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ trace!("Connected to ws");
+ ws.on_upgrade(move |socket| handle_socket(socket, addr, st))
+}
+
+async fn handle_socket(mut socket: WebSocket, _: SocketAddr, st: Arc<HotReload>) {
+ if socket.send(Message::Ping(Bytes::new())).await.is_ok() {
+ trace!("Pinged ws");
+ } else {
+ println!("Could not send ping!");
+ return;
+ }
+
+ let mut rx = st.rx.resubscribe();
+ while let Ok(a) = rx.recv().await {
+ trace!(a, "send ");
+ let res = socket.send(a.into()).await;
+ trace!(?res);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index a6963c4..773c0e3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
use std::{
collections::{HashMap, HashSet},
+ env,
net::SocketAddr,
sync::Arc,
};
@@ -50,6 +51,23 @@ fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
#[derive(Hash, Eq, PartialEq, Copy, Clone, Debug, PartialOrd, Ord, Deserialize)]
struct UserId(u64);
+impl UserId {
+ fn funny_name(&self) -> String {
+ #[rustfmt::skip]
+ const ADJECTIVES: [&'static str; 32] = [ "Beautiful", "Brave", "Bright", "Clever", "Calm", "Dull", "Eager", "Fancy", "Gentle", "Harsh", "Humble", "Kind", "Lazy", "Loud", "Narrow", "Quiet", "Quick", "Rare", "Rough", "Shiny", "Smart", "Smooth", "Strong", "Sweet", "Tall", "Tense", "Ugly", "Vast", "Weak", "Wise", "Young", "Zealous", ];
+ #[rustfmt::skip]
+ const VERBS: [&'static str; 32]= [ "Running", "Jumping", "Walking", "Talking", "Eating", "Drinking", "Laughing", "Crying", "Singing", "Dancing", "Writing", "Reading", "Sleeping", "Driving", "Cooking", "Building", "Breaking", "Thinking", "Speaking", "Listening", "Watching", "Throwing", "Catching", "Swimming", "Playing", "Fighting", "Working", "Learning", "Teaching", "Growing", "Loving", "Sharing", ];
+ #[rustfmt::skip]
+ const NOUNS: [&'static str; 32]= [ "Clock", "Cloud", "Car", "Tree", "Book", "Candle", "Mirror", "Robot", "Star", "Moon", "Sun", "River", "Rock", "Mountain", "Flower", "Train", "Shoe", "House", "Boat", "Bridge", "Key", "Pencil", "Cup", "Chair", "Phone", "Lamp", "Blanket", "Door", "Toy", "Kite", "Basket", "Balloon", ];
+
+ let u = self.0 as usize;
+ let noun = NOUNS[(u >> 0) & 31];
+ let verb = VERBS[(u >> 5) & 31];
+ let ajd = ADJECTIVES[(u >> 10) & 31];
+ format!("{} {} {}", ajd, verb, noun)
+ }
+}
+
#[derive(Serialize, Deserialize, Debug, Clone)]
struct NoteDatum {
// Epoch time in ms
@@ -248,18 +266,21 @@ impl Game {
html! {
script { ({PreEscaped("document.notes = []; ")})}
h1 { "Playing Game" }
- p { "Were playing here" }
+ p { "Play a tune" }
canvas
#canvas
- height=(256) width=(256)
- style="position:relative; background: cyan;"
+ height=(512) width=(256)
+ style="position:relative;"
{};
- script { (PreEscaped(r#"document.getElementById("canvas").onclick = (e) => {
+ script { (PreEscaped(r#"cv = document.getElementById("canvas");
+ cv.onclick = (e) => {
const notes = JSON.parse(e.target.dataset.notes ?? "[]");
notes.push({ time: Date.now(), y: e.layerY / e.target.height});
e.target.dataset.notes = JSON.stringify(notes);
- }"#))}
+ }
+ drawNoteLines(cv);
+ "#))}
button hx-post=(format!("/game/{gid}/submit"))
hx-vals=r#"js:{ "notes": document.getElementById("canvas").dataset.notes }"#
@@ -301,7 +322,7 @@ impl Game {
html! {
h1 { (format!("Round {i}/{n}"))}
- p { "Author was " (r.author.0)}
+ p { "Author was " (r.author.funny_name())}
form #form-guess {
input placeholder="Your guess..." name="guess" {};
input type="submit" hx-post=(format!("/game/{gid}/guess")) hx-target="#form-guess" {};
@@ -349,7 +370,7 @@ loadSample(soundUrl).then((sample) => {
data-accept=(accept)
hx-post=(post)
hx-swap="outerHTML"
- { (g) " (click to toggle)"}
+ { (g) }
}
}
@@ -360,6 +381,7 @@ loadSample(soundUrl).then((sample) => {
html! {
h1 { "Mark correct answers" }
+ span { "Click an answer to toggle correctness"}
ul #guesslist {
@for &u in v {
(self.guess_li(u))
@@ -394,7 +416,7 @@ loadSample(soundUrl).then((sample) => {
h1 { "Leaderboard" }
ol {
@for (uid, score) in v {
- li { span { (uid.0) ": " (score) " points" } }
+ li { span { (uid.funny_name()) ": " (score) " points" } }
}
}
@@ -465,8 +487,8 @@ async fn route_index(State(st): State<Server>) -> Response {
html! { "No current games"}
} else {
html! {
- h3 { "Games" }
- ul {
+ h3 { "Current games" }
+ ul .games-list {
@for id in &game_ids {
li { a href=(format!("/game/{id}")) { (id) } }
}
@@ -475,10 +497,12 @@ async fn route_index(State(st): State<Server>) -> Response {
};
let html = html! {
- h1 { "Good game "}
- (game_list)
+ section .main-page {
+ h1 { "Good game "}
+ (game_list)
- button hx-post="/game" { "Create game" }
+ button hx-post="/game" { "Create game" }
+ }
};
html_response(html! {
@@ -489,6 +513,7 @@ async fn route_index(State(st): State<Server>) -> Response {
meta name="viewport" content="width=device-width, initial-scale=1" {};
script src="https://unpkg.com/htmx.org@2.0.4" {};
link rel="stylesheet" type="text/css" href="static/style.css";
+ @if let Some(n) = hot_reload_script() { (n) }
}
body {
(html)
@@ -597,13 +622,14 @@ async fn get_game(
link rel="stylesheet" type="text/css" href="/static/style.css";
script src="/static/main.js" {};
+ @if let Some(n) = hot_reload_script() { (n) }
}
body hx-ext="ws" ws-connect=(format!("/game/{gid}/ws")) {
- footer { }
+ header { }
section #content {
(html)
}
- footer { span { "You are " (uid.0) } }
+ footer { span { "You are " (uid.funny_name()) } }
}
})
}
@@ -804,6 +830,25 @@ async fn identity_middleware(jar: CookieJar, mut req: Request, next: Next) -> Re
res
}
+#[cfg(debug_assertions)]
+mod hot_reload;
+#[cfg(not(debug_assertions))]
+mod hot_reload {
+ use axum::Router;
+ pub fn router(_: &str) -> Router<()> {
+ Router::new()
+ }
+}
+
+pub fn hot_reload_script() -> Option<Markup> {
+ let is_prod = env::var("STAGE").is_ok_and(|stage| stage == "prod");
+ if is_prod {
+ None
+ } else {
+ Some(html! { script { (PreEscaped(include_str!("./hot_reload.js"))) } })
+ }
+}
+
#[tokio::main]
async fn main() -> Result<()> {
let subscriber = FmtSubscriber::builder()
@@ -831,6 +876,7 @@ async fn main() -> Result<()> {
.route("/game/{gid}/judge", post(submit_judge))
.route("/game/{gid}/ws", get(game_ws))
.nest_service("/static", ServeDir::new("static"))
+ .nest_service("/dev-hr", hot_reload::router("static"))
.layer(CatchPanicLayer::custom(handle_panic))
.layer(
TraceLayer::new_for_http()
diff --git a/static/main.js b/static/main.js
index 144254f..183b117 100644
--- a/static/main.js
+++ b/static/main.js
@@ -6,10 +6,10 @@ function getContext() {
return _context;
}
-function loadSample(url) {
- return fetch(url)
- .then((response) => response.arrayBuffer())
- .then((buffer) => getContext().decodeAudioData(buffer));
+async function loadSample(url) {
+ const res = await fetch(url);
+ const buffer = res.arrayBuffer();
+ return getContext().decodeAudioData(buffer);
}
function playSoundSample(sample, sampleNote, noteToPlay) {
@@ -20,3 +20,56 @@ function playSoundSample(sample, sampleNote, noteToPlay) {
source.connect(ctx.destination);
source.start(0);
}
+
+function sigmoid(x) {
+ return 1 / (1 + Math.exp(-x));
+}
+
+function drawNoteLines(canvas) {
+ const ctx = canvas.getContext("2d");
+
+ let clicks = [];
+
+ function render() {
+ const W = 256;
+ const H = 512;
+ ctx.clearRect(0, 0, W, H);
+
+ const L = 1.0;
+ ctx.lineWidth = L;
+ ctx.strokeStyle = "#ccc";
+
+ for (let note = 0; note <= 12; note++) {
+ ctx.beginPath();
+ const y = (note / 12) * (H - L) + L / 2;
+ ctx.moveTo(0, y);
+ ctx.lineTo(W, y);
+ ctx.stroke();
+ }
+
+ let now = Date.now();
+ const R = 10.0;
+ for (const [x, y, t] of clicks) {
+ ctx.beginPath();
+ ctx.arc(x - R, y - R, 10, 0, 2 * Math.PI);
+ const tt = (now - t) / 1000; // [0, 1]
+ const alpha = sigmoid(5 - 10 * tt);
+ ctx.fillStyle = `rgb(0, 99, 228, ${alpha})`;
+ ctx.fill();
+ }
+
+ clicks = clicks.filter((o) => now < o[2] + 1_000);
+ if (clicks.length > 0) {
+ requestAnimationFrame(render);
+ }
+ }
+ render();
+
+ canvas.addEventListener("mousedown", (e) => {
+ const { layerX, layerY } = e;
+ clicks.push([layerX, layerY, Date.now()]);
+ render();
+ });
+}
+
+window.drawNoteLines = drawNoteLines;
diff --git a/static/style.css b/static/style.css
index 3b9ba9d..3dac7a4 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,18 +1,172 @@
+@import url("https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap");
+
:root {
+ font-family: "Chakra Petch", serif;
+}
+
+* {
+ font-family: "Chakra Petch", serif;
+ padding: 0;
+ margin: 0;
}
body {
+ background: hsl(214 100% 44.7%);
+ color: white;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+h1 {
+ font-size: 48px;
+ text-align: center;
+}
+
+button {
+ background: hsl(214 100% 100%);
+ color: hsl(214 100% 15%);
+ padding: 12px 24px;
+ border: none;
+ border-radius: 4px;
+ font-size: 16px;
+
+ &:hover {
+ background: hsl(214 100% 95%);
+ cursor: pointer;
+ }
+}
+
+form {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+}
+
+input:not([type]) {
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ font-size: 16px;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+input[type="submit"] {
+ background: white;
+ padding: 4px 8px;
+ border: none;
+ border-radius: 4px;
+ font-size: 16px;
+
+ &:hover {
+ background: #f0f0f0;
+ cursor: pointer;
+ }
+}
+
+body > section {
+ padding: 48px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+
+ align-items: center;
+}
+
+canvas {
+ background: white;
+ box-shadow: 0 0 10px 4px #00000080;
+ padding: 10px;
+ border-radius: 5px;
+}
+
+footer {
+ border-top: 1px solid #00000020;
+ padding: 24px;
+ text-align: center;
+}
+
+a {
+ color: #e1edfe;
+ &:visited {
+ color: #e1edfe;
+ }
+}
+
+.main-page {
+ min-width: 30rem;
+ max-width: 50vw;
+}
+
+.games-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ padding-bottom: 24px;
+
+ li {
+ display: flex;
+ a {
+ flex: 1;
+ border-radius: 20px;
+ padding: 4px 16px;
+ background: #ffffff;
+ text-decoration: none;
+ &:hover {
+ background: #f0f8ff;
+ }
+ color: #333;
+ &:visited {
+ color: #333;
+ }
+ }
+ }
+}
+
+.leaderboard {
+ font-size: 20px;
+ li {
+ margin-left: 20px;
+ }
+ padding-bottom: 36px;
}
#guesslist {
list-style: none;
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ min-width: 50vw;
+ flex-wrap: wrap;
+
+ li {
+ border-radius: 4px;
+ padding: 4px 8px;
+
+ background: white;
+ color: black;
+ }
+
[data-accept="none"] {
- background: #aaa;
+ &::before {
+ content: "❓ ";
+ }
}
[data-accept="true"] {
- background: #00ff00;
+ background: #d9ffd9;
+ &::before {
+ content: "✅ ";
+ }
}
[data-accept="false"] {
- background: #ff0000;
+ background: #ffc0c0;
+ &::before {
+ content: "❌ ";
+ }
}
}