website changes

This commit is contained in:
2025-10-27 20:01:03 -07:00
parent 1daabb52fa
commit 6d52c45bf4
5 changed files with 439 additions and 28 deletions

View File

@@ -1,20 +1,339 @@
use axum::{routing::get, Router};
use axum::{
body::{to_bytes, Body},
http::{Response as AxumResponse, StatusCode},
response::Html,
routing::{get, post},
Form, Router,
};
use futures_util::StreamExt;
use maud::{html, Markup};
use serde::Deserialize;
use tower_service::Service;
use worker::*;
/* ===================== Router ===================== */
fn router() -> Router {
Router::new().route("/", get(root))
Router::new()
.route("/", get(root))
.route("/create", post(create_game))
// /ws is handled in fetch() using CF WebSocketPair.
}
/* ===================== Cloudflare entry ===================== */
#[event(fetch)]
async fn fetch(
req: HttpRequest,
_env: Env,
_ctx: Context,
) -> Result<axum::http::Response<axum::body::Body>> {
Ok(router().call(req).await?)
async fn fetch(req: HttpRequest, _env: Env, _ctx: Context) -> Result<Response> {
// Intercept WebSocket before handing off to Axum
let is_ws = req
.headers()
.get("Upgrade")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
if req.uri().path() == "/ws" && is_ws {
return handle_ws_upgrade(req).await;
}
// Otherwise hand off to Axum and adapt Axum -> Worker Response
let axum_resp: AxumResponse<Body> = router().call(req).await?;
axum_to_worker(axum_resp).await
}
pub async fn root() -> &'static str {
"Hello Axum!"
/* Convert an Axum response into a worker::Response */
async fn axum_to_worker(ax: AxumResponse<Body>) -> Result<Response> {
let (parts, body) = ax.into_parts();
let bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
let mut resp = Response::from_bytes(bytes.to_vec())?;
resp = resp.with_status(parts.status.as_u16());
for (name, value) in parts.headers.iter() {
if let Ok(val) = value.to_str() {
resp.headers_mut().set(name.as_str(), val)?; // <- note: no ? after headers_mut()
}
}
Ok(resp)
}
/* ===================== Pages ===================== */
/// GET /
pub async fn root() -> Html<String> {
// If you want to read cookies here, switch to a custom Response and pass headers in via extractors.
let page = render_page(None);
Html::from(page)
}
/// POST /create (classic PRG with cookie flash)
#[derive(Deserialize)]
struct CreateForm {
game_name: Option<String>,
password: Option<String>,
player_name: Option<String>,
}
pub async fn create_game(Form(form): Form<CreateForm>) -> AxumResponse<Body> {
let msg = flash_msg(&form);
AxumResponse::builder()
.status(StatusCode::SEE_OTHER)
.header("Location", "/")
.header(
"Set-Cookie",
format!(
"flash={}; Path=/; Max-Age=30; HttpOnly; SameSite=Lax",
urlencoding::encode(&msg)
),
)
.body(Body::empty())
.unwrap()
}
/* ===================== WebSocket (HTMX ws-send) ===================== */
async fn handle_ws_upgrade(_req: HttpRequest) -> Result<Response> {
// Create the CF WebSocketPair at the worker layer
let pair = WebSocketPair::new()?;
let server = pair.server;
let client = pair.client;
server.accept()?;
// Spawn a local task to read messages and push OOB HTML back.
wasm_bindgen_futures::spawn_local(async move {
// Create the borrow-bound event stream *inside* the task and keep the clone alive.
let server_clone = server.clone();
let mut events = match server_clone.events() {
Ok(e) => e,
Err(_) => return,
};
while let Some(Ok(evt)) = events.next().await {
if let WebsocketEvent::Message(m) = evt {
if let Some(txt) = m.text() {
if let Ok(parsed) = serde_json::from_str::<CreateForm>(&*txt) {
let msg = flash_msg(&parsed);
let oob = render_flash_oob(&msg);
let _ = server_clone.send_with_str(oob);
} else {
let _ = server_clone.send_with_str(render_log_oob("Unrecognized message"));
}
}
}
}
});
// Return the upgrade response (must be worker::Response)
Response::from_websocket(client)
}
/* ===================== View / HTML ===================== */
fn render_page(flash: Option<&str>) -> String {
(html! {
(maud::DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Mis-Interpreter" }
// HTMX + WS Extension
script src="https://unpkg.com/htmx.org@2.0.4" {}
script src="https://unpkg.com/htmx-ext-ws@2.0.3/ws.js" {}
style { (PASTEL_CSS) }
}
body {
main class="shell" {
section class="card cartoon" {
h1 class="title" { "Mis-Interpreter" }
p class="subtitle" { "Create a game?" }
// Flash target
div id="flash" {
@if let Some(msg) = flash {
(render_flash_inline(msg))
}
}
// Live form using htmx WebSocket
div hx-ext="ws" ws-connect="/ws" {
form id="create-form" class="form" ws-send {
label class="sr" for="game_name" { "Game Name" }
input id="game_name" type="text" name="game_name"
placeholder="Game Name (Optional)" class="input";
label class="sr" for="password" { "Password" }
input id="password" type="password" name="password"
placeholder="Password (Optional)" class="input";
label class="sr" for="player_name" { "Player Name" }
input id="player_name" type="text" name="player_name"
value="John Wick" class="input";
button type="submit" class="btn" { "Create (Live)" }
}
div id="ws-log" class="wslog" {}
}
}
}
}
}
}).into_string()
}
fn render_flash_inline(msg: &str) -> Markup {
html! { div class="flash cartoon" { (escape(msg)) } }
}
fn render_flash_oob(msg: &str) -> String {
format!(
r#"<div id="flash" hx-swap-oob="true"><div class="flash cartoon">{}</div></div>"#,
escape(msg)
)
}
fn render_log_oob(text: &str) -> String {
format!(r#"<div id="ws-log" hx-swap-oob="true">{}</div>"#, escape(text))
}
/* ===================== Utilities ===================== */
fn flash_msg(form: &CreateForm) -> String {
let game = form.game_name.as_deref().unwrap_or("Untitled");
let player = form.player_name.as_deref().unwrap_or("Anonymous");
let priv_tag = if form.password.as_deref().unwrap_or("").is_empty() { "" } else { " (private)" };
format!("Created game '{}' for '{}'{priv_tag}", escape(game), escape(player))
}
// minimal safe escaping for text nodes
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
/* ===================== Theme ===================== */
const PASTEL_CSS: &str = r#"
/* ===== Pastel Blues, Cartoon Borders ===== */
:root{
/* palette */
--sky-50:#eff6ff; --sky-100:#dbeafe; --sky-200:#bfdbfe; --sky-300:#93c5fd;
--sky-400:#60a5fa; --sky-500:#3b82f6; --indigo-600:#4f46e5; --teal-300:#99f6e4;
--bg-top: color-mix(in oklab, var(--sky-100) 70%, white);
--bg-bot: color-mix(in oklab, var(--teal-300) 35%, white);
--card: #f2f7ff;
--ink: #0b1220;
--muted:#44506a;
/* cartoon outlines */
--outline: #133a84; /* deep blue stroke */
--shadow: #0e2250; /* offset shadow for sticker look */
--ring: var(--sky-400);
--field-a: #e9f2ff;
--field-b: #e6fffb;
--btn-a: #a5b4ff; /* indigo pastel */
--btn-b: #7dd3fc; /* sky pastel */
--flash-bg:#c4f1f9; /* cyan-200 */
--flash-border:#0891b2;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font:17px/1.55 ui-sans-serif, system-ui, -apple-system, Segoe UI, Inter, Roboto, Arial;
color:var(--ink);
background:
radial-gradient(1000px 700px at 15% -10%, var(--sky-200) 0%, transparent 60%),
radial-gradient(1100px 800px at 110% 0%, var(--teal-300) 0%, transparent 60%),
linear-gradient(180deg, var(--bg-top), var(--bg-bot));
}
/* ——— cartoon helpers ——— */
.cartoon{
border:4px solid var(--outline);
box-shadow: 8px 8px 0 var(--shadow);
border-radius: 26px;
}
/* layout */
.shell{min-height:100%; display:grid; place-items:center; padding:3rem 1rem}
.card{
width:min(740px, 94vw);
background: var(--card);
padding:3rem 2.5rem;
}
.card.cartoon{} /* (keep class for specificity) */
.title{
text-align:center; margin:0 0 .5rem; font-weight:900; letter-spacing:.3px;
font-size: clamp(2.1rem, 3.4vw + 1rem, 3.4rem);
/* shiny blue gradient text */
background: linear-gradient(90deg, var(--sky-500), var(--indigo-600));
-webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: 0 2px 0 #ffffff66;
}
.subtitle{ text-align:center; color:var(--muted); margin:0 0 2rem; font-weight:700 }
.flash{
background: var(--flash-bg);
border:4px solid var(--flash-border);
color:#0c4a6e;
padding:.85rem 1rem; border-radius:16px; margin:0 0 1.25rem;
box-shadow: 6px 6px 0 var(--shadow);
}
/* form */
.form{ display:grid; grid-template-columns:1fr auto; gap:16px 16px; align-items:center }
.input{
width:100%;
padding:1rem 1.1rem;
border:4px solid var(--outline);
border-radius:16px;
background:
linear-gradient(0deg, #ffffff, #ffffff) padding-box,
radial-gradient(120% 120% at 0% 0%, var(--field-a) 0%, var(--field-b) 100%) border-box;
color:var(--ink);
box-shadow: 4px 4px 0 var(--shadow);
transition: transform .06s ease, box-shadow .15s ease, border-color .15s ease;
}
.input::placeholder{color:#6b7280}
.input:focus{
outline:none; transform: translateY(-1px);
border-color: var(--ring);
box-shadow: 6px 6px 0 var(--shadow), 0 0 0 6px color-mix(in oklab, var(--ring) 40%, white);
}
/* button: chunky pill with dual-tone blue */
.btn{
padding:1rem 1.35rem;
font-weight:900;
border:4px solid var(--outline);
border-radius:999px;
color:#06223a;
background: linear-gradient(180deg,
color-mix(in oklab, var(--btn-b) 75%, white),
color-mix(in oklab, var(--btn-a) 85%, white));
cursor:pointer;
box-shadow: 6px 6px 0 var(--shadow);
transition: transform .07s ease, filter .15s ease, box-shadow .15s ease;
}
.btn:hover{ filter:brightness(1.06) saturate(1.05) }
.btn:active{ transform: translateY(2px); box-shadow: 4px 4px 0 var(--shadow) }
/* accessibility */
.sr{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0 }
/* responsive */
@media (max-width: 560px){
.form{ grid-template-columns: 1fr }
.btn{ width:100% }
}
"#;