summaryrefslogtreecommitdiff
path: root/src
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 /src
parent2f2beb79720ec9fd35b993e7ad907ecf32005cde (diff)
downloadmusicgame-42fd8cd3560047c88a2167a4d5c5845dd81eb3ec.tar.gz
musicgame-42fd8cd3560047c88a2167a4d5c5845dd81eb3ec.zip
Add styling and hot reload
Diffstat (limited to 'src')
-rw-r--r--src/hot_reload.js81
-rw-r--r--src/hot_reload.rs109
-rw-r--r--src/main.rs76
3 files changed, 251 insertions, 15 deletions
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()