use leptos::prelude::*; use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; use leptos_router::{ components::{Route, Router, Routes}, hooks::{use_location, use_navigate, use_query_map}, path, NavigateOptions, }; use serde::{Deserialize, Serialize}; use crate::answers::{Answer, TransitionAnswer}; use crate::auth::{current_user, User}; use crate::content::{is_qualified, Alternative, Question, Transition}; use crate::resource::get_resource; /// The visible site name/wordmark - "portal" is just this codebase's /// working title, not necessarily what any given deployment is called. /// Baked in at *build* time (`option_env!`, not `std::env::var`) so the /// ssr and hydrate targets - compiled separately - end up with the /// identical value without a runtime round-trip or context-passing /// between them; set `SITE_NAME` for both `cargo leptos` invocations /// (ssr and hydrate) to change it, e.g. `SITE_NAME=uhhm`. CSS uppercases /// the wordmark for display regardless of the source casing here. const SITE_NAME: &str = match option_env!("SITE_NAME") { Some(name) => name, None => "portal", }; pub fn shell(options: LeptosOptions) -> impl IntoView { view! { } } #[component] pub fn App() -> impl IntoView { provide_meta_context(); view! { <Router> <Routes fallback=|| view! { <NotFound/> }> <Route path=path!("") view=QuestionPage/> <Route path=path!("/*any") view=QuestionPage/> </Routes> </Router> } } #[component] fn QuestionPage() -> impl IntoView { let location = use_location(); let query = use_query_map(); let path = Memo::new(move |_| { let p = location.pathname.get(); if p.is_empty() { "/".to_string() } else { p } }); let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); let question = Resource::new(move || path.get(), get_question); let user = Resource::new(|| (), |_| current_user()); view! { <Suspense fallback=|| { view! { <main class="loading"> <p>"loading…"</p> </main> } }> {move || { // Both read unconditionally (not nested inside the match // below) so Suspense tracks both from the first pass, // rather than only discovering `user` once `question` // has already resolved. let user_res = user.get(); let question_res = question.get(); question_res .map(|res| match res { Ok(Some(q)) => { let current = user_res.and_then(|r| r.ok()).flatten(); view! { <QuestionView question=q parent_hash=parent_hash.get() user=current /> } .into_any() } _ => view! { <NotFound/> }.into_any(), }) }} </Suspense> } } #[component] fn QuestionView( question: Question, parent_hash: Option<String>, user: Option<User>, ) -> impl IntoView { let question_id = question.id.clone(); // Generic: any question with `qualifies` set renders this same gate // instead of its alternatives - "/review" isn't a special case, it's // just a question that happens to have `qualifies` set. if !is_qualified(user.as_ref(), &question) { let signed_in = user.is_some(); return view! { <Hero title=question.name.clone() description=question.description.clone() show_yes=question_id == "/" /> <div class="alternatives"> <section class="alt-card gate-card"> {if signed_in { view! { <p>"This part of the site is for organizational owners — sign in with that account to take a look."</p> } .into_any() } else { view! { <a class="alt-submit" href="/auth/login" rel="external"> "Sign in" </a> } .into_any() }} </section> </div> } .into_any(); } view! { <Hero title=question.name.clone() description=question.description.clone() show_yes=question_id == "/" /> <div class="alternatives"> <For each=move || question.alternatives.clone() key=|a| a.name.clone() children=move |alt: Alternative| { view! { <AlternativeCard question_id=question_id.clone() alternative=alt parent_hash=parent_hash.clone() /> } } /> </div> } .into_any() } // Binds the actual "YES - Rasterized Lines" piece live at uhhm.no // (ported to `public/yes.js`, an ES module now instead of a page-owning // script) as a typed JS class - the idiomatic wasm-bindgen way to drive // an existing JS module from Rust, rather than re-implementing canvas // animation logic here. #[cfg(feature = "hydrate")] mod yes { use wasm_bindgen::prelude::*; #[wasm_bindgen(module = "/yes.js")] extern "C" { #[wasm_bindgen(js_name = RasterizedYES)] pub type RasterizedYes; #[wasm_bindgen(constructor, js_class = "RasterizedYES")] pub fn new() -> RasterizedYes; #[wasm_bindgen(method)] pub fn stop(this: &RasterizedYes); } } #[component] fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { // Only the landing page gets the full interactive piece - it's the // one page this is actually "the" hero for; other pages (thank-you // pages, /review) get the plain dark header below. Starts the // animation once the canvas elements exist (post-mount, via // Effect), and explicitly stops the requestAnimationFrame loop on // unmount - the original page-owning script never needed this since // navigating away meant a full document unload, which doesn't // happen in an SPA. #[cfg(feature = "hydrate")] if show_yes { // `on_cleanup` requires Send + Sync (even single-threaded, wasm), // which a JS-backed value never is - `StoredValue`'s `LocalStorage` // variant is the established way around this (same pattern // cnats' WebRTC call state uses for its own non-Send peer map). let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> = StoredValue::new_local(None); Effect::new(move |_| { instance.set_value(Some(yes::RasterizedYes::new())); }); on_cleanup(move || { instance.update_value(|opt| { if let Some(inst) = opt.take() { inst.stop(); } }); }); } view! { <header class="hero" class:hero-yes=show_yes> {show_yes .then(|| { view! { <div class="hero-canvas" aria-hidden="true"> <canvas id="lineCanvas"></canvas> <canvas id="rasterCanvas"></canvas> </div> } })} <div class="hero-copy"> <a class="wordmark" href="/"> {SITE_NAME} </a> <h1>{title}</h1> <p>{description}</p> </div> </header> } } #[component] fn AlternativeCard( question_id: String, alternative: Alternative, parent_hash: Option<String>, ) -> impl IntoView { let has_action = alternative.action.is_some(); // File fields don't fit a live-typed RwSignal<String> - they get // their own map of element refs, read (and uploaded) only at submit // time, and are excluded from `field_map` below. let mut field_map: std::collections::HashMap<String, RwSignal<String>> = std::collections::HashMap::new(); let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> = std::collections::HashMap::new(); for feature in &alternative.features { for req in &feature.requirements { if req.kind == "file" { file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new); } else { field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(String::new())); } } } // Prefixes field DOM ids with the alternative too, not just the // question - two alternatives on the same page (e.g. both asking for // "email") would otherwise mint the same id twice, which is invalid // HTML and makes <label for=…> pick the wrong input. let field_prefix = format!( "{question_id}-{}", alternative.name.to_lowercase().replace(' ', "-") ); let submit = ServerAction::<SubmitAnswer>::new(); let navigate = use_navigate(); let question_id_for_submit = question_id.clone(); let alt_name_for_submit = alternative.name.clone(); let parent_hash_for_submit = parent_hash.clone(); let field_map_for_submit = field_map.clone(); let file_refs_for_submit = file_refs.clone(); let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let field_map_for_submit = field_map_for_submit.clone(); let file_refs_for_submit = file_refs_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone(); leptos::task::spawn_local(async move { let mut map = serde_json::Map::new(); for (name, sig) in field_map_for_submit.iter() { map.insert(name.clone(), serde_json::Value::String(sig.get())); } // Only used under hydrate (file uploads are a browser-only // concern); referenced unconditionally so an `ssr` build // doesn't warn about it going unused. let _ = &file_refs_for_submit; #[cfg(feature = "hydrate")] for (name, file_ref) in file_refs_for_submit.iter() { let Some(input) = file_ref.get_untracked() else { continue; }; let Some(files) = input.files() else { continue }; let Some(file) = files.get(0) else { continue }; match upload_file(&file).await { Ok(key) => { map.insert(name.clone(), serde_json::Value::String(key)); } Err(e) => { leptos::logging::error!("upload of {name} failed: {e:?}"); } } } let responses_json = serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default(); submit.dispatch(SubmitAnswer { question_id: question_id_for_submit, alternative: alt_name_for_submit, parent_hash: parent_hash_for_submit, responses_json, }); }); }; Effect::new(move |_| { if let Some(Ok(result)) = submit.value().get() { if let Some(next) = result.next { navigate( &format!("{next}?chain={}", result.chain_hash), NavigateOptions::default(), ); } } }); let button_label = alternative .consequence .first() .cloned() .unwrap_or_else(|| "Send".to_string()); view! { <section class="alt-card"> <h2>{alternative.name.clone()}</h2> <p class="alt-description">{alternative.description.clone()}</p> <For each=move || alternative.encouragements.clone() key=|e| e.clone() children=move |e| view! { <p class="alt-encouragement">{e}</p> } /> <div class="features"> <For each={ let features = alternative.features.clone(); move || features.clone() } key=|f| f.name.clone() children={ let field_map = field_map.clone(); let file_refs = file_refs.clone(); let field_prefix = field_prefix.clone(); let question_id = question_id.clone(); move |feature| { let field_map = field_map.clone(); let file_refs = file_refs.clone(); let field_prefix = field_prefix.clone(); let question_id = question_id.clone(); let resource = feature.resource.clone(); let feature_name = feature.name.clone(); view! { <div class="feature"> <Show when={ let name = feature.name.clone(); move || !name.is_empty() }> <h3>{feature.name.clone()}</h3> </Show> <Show when={ let description = feature.description.clone(); move || !description.is_empty() }> <p>{feature.description.clone()}</p> </Show> {resource.map(|spec| { view! { <ResourceFeature question_id=question_id.clone() feature_name=feature_name.clone() transitions=spec.transitions.clone() /> } })} <For each={ let reqs = feature.requirements.clone(); move || reqs.clone() } key=|r| r.name.clone() children={ let field_map = field_map.clone(); let file_refs = file_refs.clone(); let field_prefix = field_prefix.clone(); move |req| { let field_id = format!("{}-{}", field_prefix, req.name); let label_for = field_id.clone(); let label_text = view! { <span class="field-label"> {req.display_label()} {(!req.optional).then(|| view! { <span class="required">"*"</span> })} </span> }; if req.kind == "file" { let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new); return view! { <label class="field" for=label_for> {label_text} <input id=field_id type="file" accept=req.accept.clone().unwrap_or_default() multiple=req.multiple required=!req.optional node_ref=file_ref /> </label> } .into_any(); } let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new())); view! { <label class="field" for=label_for> {label_text} <Show when={ let kind = req.kind.clone(); move || kind == "textarea" } fallback={ let kind = req.kind.clone(); let placeholder = req.placeholder.clone().unwrap_or_default(); let field_id = field_id.clone(); let required = !req.optional; move || { view! { <input id=field_id.clone() type=kind.clone() placeholder=placeholder.clone() required=required prop:value=move || sig.get() on:input=move |ev| sig.set(event_target_value(&ev)) /> } } } > { let field_id = field_id.clone(); let placeholder = req.placeholder.clone().unwrap_or_default(); let required = !req.optional; view! { <textarea id=field_id.clone() placeholder=placeholder.clone() required=required prop:value=move || sig.get() on:input=move |ev| sig.set(event_target_value(&ev)) ></textarea> } } </Show> </label> } .into_any() } } /> </div> } } } /> </div> {has_action .then(|| { view! { <form on:submit=on_submit> <button type="submit" class="alt-submit" disabled=move || submit.pending().get() > {button_label.clone()} </button> </form> } })} </section> } } /// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`). /// Fully generic: it has no idea what "/review" or "applicants" are - it /// just fetches `question_id`/`feature_name`'s data and renders it /// according to its own shape (see `ResourceValue`), with one button /// per `transitions` entry when the data looks like a list of answers. #[component] fn ResourceFeature( question_id: String, feature_name: String, transitions: Vec<Transition>, ) -> impl IntoView { let data = Resource::new( { let question_id = question_id.clone(); let feature_name = feature_name.clone(); move || (question_id.clone(), feature_name.clone()) }, |(q, f)| get_resource(q, f), ); view! { <div class="resource"> <Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }> {move || { let question_id = question_id.clone(); let feature_name = feature_name.clone(); let transitions = transitions.clone(); data.get() .map(|res| match res { Ok(value) => { view! { <ResourceValue question_id=question_id feature_name=feature_name transitions=transitions value=value /> } .into_any() } Err(e) => { view! { <p class="resource-error">{e.to_string()}</p> }.into_any() } }) }} </Suspense> </div> } } /// Renders whatever `get_resource` returned, purely from its shape: an /// array that parses as `answers::Answer` renders as a list (with /// transition buttons, if any were declared); anything else renders as /// a plain, read-only dump. No `kind`/render-mode tag anywhere - the /// data decides. #[component] fn ResourceValue( question_id: String, feature_name: String, transitions: Vec<Transition>, value: serde_json::Value, ) -> impl IntoView { if let serde_json::Value::Array(items) = &value { if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) { let mut answers = answers; answers.sort_by(|a, b| { let open = |s: &str| s != crate::answers::OPEN_STATE; open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms)) }); return view! { <div class="answer-list"> <For each=move || answers.clone() key=|a| a.id.clone() children={ let question_id = question_id.clone(); let feature_name = feature_name.clone(); let transitions = transitions.clone(); move |answer: Answer| { view! { <AnswerRow question_id=question_id.clone() feature_name=feature_name.clone() transitions=transitions.clone() answer=answer /> } } } /> </div> } .into_any(); } if items.is_empty() { return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any(); } } view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any() } #[component] fn AnswerRow( question_id: String, feature_name: String, transitions: Vec<Transition>, answer: Answer, ) -> impl IntoView { let is_open = answer.state == crate::answers::OPEN_STATE; let responses = answer .responses .as_object() .cloned() .unwrap_or_default(); let submitted_ms = answer.submitted_ms; let state = answer.state.clone(); let item_id = answer.id.clone(); let transition = ServerAction::<TransitionAnswer>::new(); view! { <article class="answer-row" class:decided=move || !is_open> <div class="answer-fields"> <For each={ let responses = responses.clone(); move || responses.clone().into_iter().collect::<Vec<_>>() } key=|(k, _)| k.clone() children=move |(key, value)| { let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string()); view! { <div class="answer-field"> <span class="answer-field-key">{key}</span> <span class="answer-field-value">{text}</span> </div> } } /> </div> <div class="answer-meta"> <span class="answer-state">{state}</span> <span class="answer-time">{format_ms(submitted_ms)}</span> </div> <Show when={ let transitions = transitions.clone(); move || is_open && !transitions.is_empty() }> <div class="answer-actions"> <For each={ let transitions = transitions.clone(); move || transitions.clone() } key=|t| t.to.clone() children={ let question_id = question_id.clone(); let feature_name = feature_name.clone(); let item_id = item_id.clone(); move |t: Transition| { let question_id = question_id.clone(); let feature_name = feature_name.clone(); let item_id = item_id.clone(); let to = t.to.clone(); view! { <button class="answer-action" on:click=move |_| { transition.dispatch(TransitionAnswer { question_id: question_id.clone(), feature_name: feature_name.clone(), item_id: item_id.clone(), to: to.clone(), }); } disabled=move || transition.pending().get() > {t.label.clone()} </button> } } } /> </div> </Show> </article> } } fn format_ms(ms: i64) -> String { #[cfg(feature = "ssr")] { use chrono::TimeZone; chrono::Utc .timestamp_millis_opt(ms) .single() .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) .unwrap_or_else(|| ms.to_string()) } #[cfg(not(feature = "ssr"))] { ms.to_string() } } #[component] fn NotFound() -> impl IntoView { view! { <main class="not-found"> <h1>"Nothing here"</h1> <a href="/">"back to the start"</a> </main> } } #[server] pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> { use crate::server::AppState; let state = expect_context::<AppState>(); Ok(state.questions.get(&path).cloned()) } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SubmitResult { pub next: Option<String>, pub chain_hash: String, } #[server] pub async fn submit_answer( question_id: String, alternative: String, parent_hash: Option<String>, responses_json: String, ) -> Result<SubmitResult, ServerFnError> { use crate::answers::store_answer; use crate::auth::{User, SESSION_USER_KEY}; use crate::chain::hash_node; use crate::content::is_qualified; use crate::events::{emit_answer_submitted, AnswerSubmitted}; use crate::server::AppState; let state = expect_context::<AppState>(); let question = state .questions .get(&question_id) .ok_or_else(|| ServerFnError::new("unknown question"))?; let session: tower_sessions::Session = leptos_axum::extract().await?; let user = session .get::<User>(SESSION_USER_KEY) .await .map_err(|e| ServerFnError::new(e.to_string()))?; if !is_qualified(user.as_ref(), question) { return Err(ServerFnError::new("not authorized for this question")); } let alt = question .alternatives .iter() .find(|a| a.name == alternative) .ok_or_else(|| ServerFnError::new("unknown alternative"))?; let next = alt.action.clone(); let record_as = alt.record_as.clone(); let responses: serde_json::Value = serde_json::from_str(&responses_json) .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?; let parent_hashes: Vec<String> = parent_hash.into_iter().collect(); let timestamp_ms = chrono::Utc::now().timestamp_millis(); let chain_hash = hash_node(&question_id, &parent_hashes, &responses, timestamp_ms); emit_answer_submitted( &state.nats, &AnswerSubmitted { chain_hash: chain_hash.clone(), parent_hashes, question_id: question_id.clone(), alternative: alternative.clone(), responses: responses.clone(), timestamp_ms, }, ) .await .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; // Best-effort: a bucket-write hiccup shouldn't fail a submission the // NATS event has already recorded. if let Some(bucket) = &record_as { if let Err(e) = store_answer( &state.jetstream, bucket, chain_hash.clone(), &question_id, &alternative, &responses, timestamp_ms, ) .await { tracing::error!("failed to store answer in {bucket}: {e}"); } } Ok(SubmitResult { next, chain_hash }) } /// Uploads one file to `POST /upload` and returns its stored object key /// - the value a `type: file` requirement contributes to /// `responses_json`, same as any other field. A plain `fetch`, not a /// Leptos server fn: those aren't built for binary bodies, and the /// server side is a raw Axum multipart handler (`src/upload.rs`) for /// the same reason. #[cfg(feature = "hydrate")] async fn upload_file(file: &web_sys::File) -> Result<String, wasm_bindgen::JsValue> { use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::{FormData, Request, RequestInit, Response}; let form = FormData::new()?; form.append_with_blob("file", file)?; let opts = RequestInit::new(); opts.set_method("POST"); opts.set_body_opt_form_data(Some(&form)); let request = Request::new_with_str_and_init("/upload", &opts)?; let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; let resp: Response = JsFuture::from(window.fetch_with_request(&request)) .await? .dyn_into()?; if !resp.ok() { return Err(JsValue::from_str(&format!( "upload failed with status {}", resp.status() ))); } let json = JsFuture::from(resp.json()?).await?; js_sys::Reflect::get(&json, &JsValue::from_str("key"))? .as_string() .ok_or_else(|| JsValue::from_str("upload response had no key")) }