diff options
| -rw-r--r-- | Cargo.lock | 163 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/hot_reload.js | 81 | ||||
| -rw-r--r-- | src/hot_reload.rs | 109 | ||||
| -rw-r--r-- | src/main.rs | 76 | ||||
| -rw-r--r-- | static/main.js | 61 | ||||
| -rw-r--r-- | static/style.css | 160 |
7 files changed, 623 insertions, 28 deletions
@@ -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" @@ -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: "❌ "; + } } } |
