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, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::auth::{current_user, User}; use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition}; use crate::resource::{get_requirement_binding, get_requirement_options, 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! { // Two prefers-color-scheme-scoped links, not one SVG with // an embedded @media query - confirmed via real-device // testing (see infrastructure's gitea-head.tmpl) that an // SVG's own internal @media doesn't reliably re-evaluate // once loaded via / on Safari/iOS. } } #[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 query_email = Memo::new(move |_| query.with(|q| q.get("email"))); let question = Resource::new( move || (path.get(), parent_hash.get()), |(path, chain)| get_question(path, chain), ); let user = Resource::new(|| (), |_| current_user()); let site = Resource::new(|| (), |_| get_site()); 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(); let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default(); question_res .map(|res| match res { Ok(Some(page)) => { let current = user_res.and_then(|r| r.ok()).flatten(); view! { // A second <Title> outranks App's // SITE_NAME fallback only when content // actually declares one. {site_cfg .title .clone() .map(|t| view! { <Title text=t/> })} <QuestionView question=page.question chain_gate=page.chain_gate parent_hash=parent_hash.get() query_email=query_email.get() user=current site=site_cfg /> } .into_any() } _ => view! { <NotFound/> }.into_any(), }) }} </Suspense> } } #[component] fn QuestionView( question: Question, chain_gate: Option<(String, String)>, parent_hash: Option<String>, query_email: Option<String>, user: Option<User>, site: SiteConfig, ) -> impl IntoView { let question_id = question.id.clone(); // The provenance counterpart to the qualifies gate below: a // requires_chain page whose visitor holds no verifiable lineage to // the required question renders a pointer there instead of its // alternatives. if let Some((target, target_name)) = chain_gate { let label = if target_name.is_empty() { target.clone() } else { target_name }; return view! { <Hero title=question.name.clone() description=question.description.clone() landing=question_id == "/" site=site.clone() /> <div class="alternatives"> <section class="alt-card gate-card"> <p>"This page follows from an answer you don't seem to carry yet."</p> <a class="alt-submit" href=target> {label} </a> </section> </div> } .into_any(); } // 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() landing=question_id == "/" site=site.clone() /> <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=format!("/auth/login?redirect={question_id}") rel="external" > "Sign in" </a> } .into_any() }} </section> </div> } .into_any(); } // Swiper Element only registers its custom elements when its // (vendored, ~180KB) script runs - loaded once per page, and only // on pages where some alternative actually declares a deck. let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1); let has_chain = parent_hash.is_some(); view! { {needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })} <Hero title=question.name.clone() description=question.description.clone() landing=question_id == "/" site=site.clone() /> <div class="alternatives"> <For each={ let parent_hash = parent_hash.clone(); let query_email = query_email.clone(); move || { // A self_transition alternative has nothing to show // (no action to submit, no button target) without // both link params present - hide it rather than // render a dead-looking empty card for anyone who // reaches this question the normal way. let has_link = parent_hash.is_some() && query_email.is_some(); question .alternatives .clone() .into_iter() .filter(|a| a.self_transition.is_none() || has_link) .collect::<Vec<_>>() } } key=|a| a.name.clone() children={ let question_id = question_id.clone(); move |alt: Alternative| { view! { <AlternativeCard question_id=question_id.clone() alternative=alt parent_hash=parent_hash.clone() query_email=query_email.clone() /> } .into_any() } } /> </div> {question .responsible .clone() .map(|r| { view! { <ResponsibleNote responsible=r/> } })} <QuestionNav current_id=question_id has_chain=has_chain/> } .into_any() } /// Every other question the visitor currently qualifies for, as a /// small nav - how a concern reaches the people it speaks to without /// claiming front-page space (an owner also sees the gated desks here). #[component] fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView { let nav = Resource::new(move || has_chain, list_qualifying_questions); view! { <Suspense fallback=|| ()> {move || { let current_id = current_id.clone(); nav.get().and_then(|res| res.ok()).map(|items| { let others: Vec<(String, String)> = items .into_iter() .filter(|(id, _)| *id != current_id) .collect(); (!others.is_empty()).then(|| view! { <nav class="question-nav"> <span class="question-nav-lead">"Also worth asking"</span> <For each=move || others.clone() key=|(id, _)| id.clone() children=|(id, name): (String, String)| { view! { <a href=id>{name}</a> }.into_any() } /> </nav> }) }) }} </Suspense> } } /// "Asked by X — contact them if you get stuck." /// The mailto address is assembled from `data-user`/`data-domain` on a /// real mouse event, never baked into the server-rendered `href` - /// bulk scrapers regex HTML for `mailto:` strings, and this keeps one /// out of what they see. Not a defense against a targeted scrape, just /// against the generic bot crawl. #[component] fn ResponsibleNote(responsible: Responsible) -> impl IntoView { let (user, domain) = responsible .contact .split_once('@') .map(|(u, d)| (u.to_string(), d.to_string())) .unwrap_or((responsible.contact.clone(), String::new())); // Zero captures (reads data-user/data-domain off the target element // itself, not from the closure's environment) - Copy, so the same // value works for both `on:` attributes below without cloning. let assemble = move |ev: leptos::ev::MouseEvent| { #[cfg(feature = "hydrate")] { let target = event_target::<web_sys::HtmlAnchorElement>(&ev); if let (Some(user), Some(domain)) = (target.get_attribute("data-user"), target.get_attribute("data-domain")) { let _ = target.set_attribute("href", &format!("mailto:{user}@{domain}")); } } #[cfg(not(feature = "hydrate"))] let _ = &ev; }; view! { <small class="question-responsible"> "Asked by " {responsible.name.clone()} " — " <a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble> "contact them" </a> " if you get stuck." </small> } } // 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::*; // raw_module (a runtime URL, not a bundled snippet) on purpose: // the file lives in public/ so it's served at the stable /yes.js // - the same URL the hero's inline early-mount script imports. // A bundled snippet would sit under a per-build hashed // /pkg/snippets/ path the inline script couldn't know. #[wasm_bindgen(raw_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); } } // Same idiomatic-typed-module approach as `yes` above, for the // prosekit-backed rich-text requirement kind (`prosekit-editor.js`, // project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative // to the crate root, not the `public/` static-asset dir, same as // `yes.js` below). `mount_editor` hands the editor a container node // plus the paired // hidden `<input>` it mirrors its HTML into and fires real `input` // events on - see `mount_editor`'s call site in `AlternativeCard`, and // the JS module itself for why a hidden-input bridge rather than a // bespoke Rust<->JS value channel. #[cfg(feature = "hydrate")] mod prosekit { use wasm_bindgen::prelude::*; #[wasm_bindgen(module = "/prosekit-editor.js")] extern "C" { #[wasm_bindgen(js_name = mountEditor)] pub fn mount_editor( container: &web_sys::HtmlDivElement, hidden: &web_sys::HtmlInputElement, initial: &str, ); } } // The `type: gesture` drawing widget (`gesture.js`, crate root): // prosekit's hidden-input bridge for the value, yes.js's typed-handle // lifecycle for cleanup - it may own a live WebSocket to a // redoal-relay, which SPA navigation must close (`stop()`). #[cfg(feature = "hydrate")] mod gesture { use wasm_bindgen::prelude::*; #[wasm_bindgen(module = "/gesture.js")] extern "C" { pub type GestureWidget; #[wasm_bindgen(js_name = mountGesture)] pub fn mount_gesture( container: &web_sys::HtmlDivElement, hidden: &web_sys::HtmlInputElement, relay_url: &str, ) -> GestureWidget; #[wasm_bindgen(js_name = mountGestureHero)] pub fn mount_gesture_hero( container: &web_sys::HtmlDivElement, relay_url: &str, ) -> GestureWidget; #[wasm_bindgen(method)] pub fn stop(this: &GestureWidget); } } #[component] fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView { // Only the landing page gets a full interactive piece - it's the // one page a hero is actually "the" hero for; other pages // (thank-you pages, /review) get the plain dark header below. // WHICH piece is the content repo's call (`site.yaml`'s // hero.kind): the YES canvas (default - uhhm's look), a redoal // gesture canvas, or nothing. let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() }; let show_yes = hero_kind == "yes"; let show_gesture = hero_kind == "gesture"; let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string()); let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string()); // Starts the YES animation once the canvas elements exist, 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. let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new(); let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new(); #[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); // Gated on the canvas NodeRef resolving, not just "the Effect // ran" - on a fresh page load the DOM is already there by the // time this fires, but navigating back to `/` client-side hit a // real race: the Effect ran before the new view's <canvas> was // actually inserted, RasterizedYES::new() (yes.js) did an // unchecked `document.getElementById(...).getContext(...)` on // null and threw mid-reactive-update - which then corrupted // wasm_bindgen_futures' single-threaded executor state badly // enough to panic ("RefCell already borrowed") on the next // tick. Waiting for the NodeRef itself guarantees DOM presence // the same way the prosekit editor's own mount Effect does. Effect::new(move |_| { if raster_ref.get().is_none() { return; } if instance.with_value(|i| i.is_some()) { return; } // Adopt the hero's inline early-mount instance (started at // HTML parse time, long before this wasm was even fetched) // instead of starting a second animation. The flag covers // the opposite ordering too: an inline script that runs // after this sees it and stays inert. use wasm_bindgen::JsCast; let global = js_sys::global(); let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into()); let early = js_sys::Reflect::get(&global, &"__yesEarly".into()) .ok() .filter(|v| !v.is_undefined() && !v.is_null()); let inst = match early { Some(v) => { let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into()); v.unchecked_into::<yes::RasterizedYes>() } None => yes::RasterizedYes::new(), }; instance.set_value(Some(inst)); }); on_cleanup(move || { instance.update_value(|opt| { if let Some(inst) = opt.take() { inst.stop(); } }); }); } // The gesture hero mounts the same widget the `type: gesture` // requirement uses, in hero mode (no form field; echoes render as // ambient strokes on the canvas itself). Same StoredValue + // on_cleanup shape as the YES piece - it may own a live WebSocket. #[cfg(feature = "hydrate")] if show_gesture { let relay = site.hero.relay.clone().unwrap_or_default(); let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> = StoredValue::new_local(None); Effect::new(move |_| { let Some(container) = gesture_ref.get() else { return; }; if widget.with_value(|w| w.is_some()) { return; } widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay))); }); on_cleanup(move || { widget.update_value(|opt| { if let Some(w) = opt.take() { w.stop(); } }); }); } view! { <header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture> {show_yes .then(|| { view! { <div class="hero-canvas" aria-hidden="true"> <canvas id="lineCanvas"></canvas> <canvas id="rasterCanvas" node_ref=raster_ref></canvas> </div> // Starts the animation at HTML parse time // instead of waiting out the wasm bundle's // fetch + hydration; the hydrate Effect above // adopts (never duplicates) the instance, and // the guards make either execution order safe. <script type="module" inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });" ></script> } })} {show_gesture .then(|| { view! { <div class="hero-draw" node_ref=gesture_ref></div> } })} <div class="hero-copy"> <a class="wordmark" href="/"> <img src=wordmark alt=site_title/> </a> <h1>{title}</h1> <p>{description}</p> </div> </header> } } /// An alternative's banner imagery: nothing, a plain image, or - for /// several urls - a Swiper Element card deck. The `swiper-container`/ /// `swiper-slide` custom elements render inert server-side and upgrade /// once the vendored bundle registers them (loaded per-page by /// `QuestionView` only when some alternative actually needs it). #[component] fn AltImages(images: Vec<String>) -> impl IntoView { match images.len() { 0 => ().into_any(), 1 => view! { <img class="alt-image" src=images[0].clone() alt="" /> }.into_any(), _ => view! { <swiper-container class="alt-image-deck" effect="cards" grab-cursor="true"> <For each=move || images.clone() key=|src| src.clone() children=|src: String| view! { <swiper-slide> <img class="alt-image" src=src alt="" /> </swiper-slide> }.into_any() /> </swiper-container> } .into_any(), } } #[component] fn AlternativeCard( question_id: String, alternative: Alternative, parent_hash: Option<String>, query_email: Option<String>, ) -> impl IntoView { // A link carrying both ?chain= and ?email= against an alternative // that declares a self_transition renders as a one-button // confirmation instead of the normal form - the visitor already // holds everything the action needs (see // `answers::self_transition_answer`), there's nothing left to type. if let (Some(st), Some(item_id), Some(email)) = (alternative.self_transition.clone(), parent_hash.clone(), query_email) { let self_transition = ServerAction::<SelfTransitionAnswer>::new(); let question_id_for_action = question_id.clone(); let alt_name_for_action = alternative.name.clone(); let item_id_for_action = item_id.clone(); let email_for_action = email.clone(); let label = st.label.clone(); return view! { <section class="alt-card"> <h2>{alternative.name.clone()}</h2> <p class="alt-description">{alternative.description.clone()}</p> {move || { let question_id_for_action = question_id_for_action.clone(); let alt_name_for_action = alt_name_for_action.clone(); let item_id_for_action = item_id_for_action.clone(); let email_for_action = email_for_action.clone(); let label = label.clone(); match self_transition.value().get() { None => view! { <button class="alt-submit" disabled=move || self_transition.pending().get() on:click=move |_| { self_transition.dispatch(SelfTransitionAnswer { question_id: question_id_for_action.clone(), alternative: alt_name_for_action.clone(), item_id: item_id_for_action.clone(), email: email_for_action.clone(), }); } > {label.clone()} </button> } .into_any(), Some(Ok(())) => view! { <p>"Done."</p> }.into_any(), Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(), } }} </section> } .into_any(); } let has_action = alternative.action.is_some(); let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty()); // Nothing to submit, nothing to confirm, nothing to record: the // alternative is a gateway to another question, and its button is // just a link (a form submit would be a click that does nothing). let is_gateway = has_action && !has_requirements && alternative.record_as.is_none() && alternative .features .iter() .all(|f| f.resource.as_ref().is_none_or(|r| r.transitions.is_empty())); // Shared across every resource feature on this alternative: which // row(s) have a transition selected but not yet confirmed, keyed by // (feature_name, item_id) so a batch can span more than one // resource. One button confirms every selection at once (see // `on_submit` below) instead of a Confirm button per row. let pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>> = RwSignal::new(std::collections::HashMap::new()); let transition_batch = ServerAction::<TransitionAnswers>::new(); Effect::new(move |_| { if let Some(Ok(())) = transition_batch.value().get() { pending_transitions.set(std::collections::HashMap::new()); } }); // Text fields live in field_map; file fields (element refs, read at // submit) and select fields (a set of ids, plus which are multiple) // each need their own shape. 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(); let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> = std::collections::HashMap::new(); let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new(); // Gesture fields share field_map (their hidden input carries a // JSON string), but submit re-parses them so the stored answer // holds a real {points, key} object, not a double-encoded string. let mut gesture_fields: std::collections::HashSet<String> = std::collections::HashSet::new(); for feature in &alternative.features { for req in &feature.requirements { if req.kind == "file" { file_refs.entry(req.name.clone()).or_default(); } else if req.kind == "select" { select_field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(Vec::new())); select_multi.insert(req.name.clone(), req.multiple); } else { if req.kind == "gesture" { gesture_fields.insert(req.name.clone()); } field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(String::new())); } } } // DOM ids are prefixed with question AND alternative - two // alternatives on one page can both ask for "email", and duplicate // ids would make <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 select_field_map_for_submit = select_field_map.clone(); let select_multi_for_submit = select_multi.clone(); let gesture_fields_for_submit = gesture_fields.clone(); let question_id_for_transition = question_id.clone(); let alt_name_for_transition = alternative.name.clone(); let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let pending = pending_transitions.get_untracked(); if !pending.is_empty() { let items = pending .into_iter() .map(|((feature_name, item_id), to)| TransitionItem { feature_name, item_id, to }) .collect(); transition_batch.dispatch(TransitionAnswers { question_id: question_id_for_transition.clone(), alternative: alt_name_for_transition.clone(), items, }); } if !has_requirements { return; } let field_map_for_submit = field_map_for_submit.clone(); let file_refs_for_submit = file_refs_for_submit.clone(); let select_field_map_for_submit = select_field_map_for_submit.clone(); let select_multi_for_submit = select_multi_for_submit.clone(); let gesture_fields_for_submit = gesture_fields_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() { let value = if gesture_fields_for_submit.contains(name) { // The widget's hidden input holds {points, key} as // JSON; store the object itself (empty/never-drawn // becomes null, not ""). serde_json::from_str(&sig.get()).unwrap_or(serde_json::Value::Null) } else { serde_json::Value::String(sig.get()) }; map.insert(name.clone(), value); } for (name, sig) in select_field_map_for_submit.iter() { let ids = sig.get(); let multiple = select_multi_for_submit.get(name).copied().unwrap_or(false); let value = if multiple { serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect()) } else { serde_json::Value::String(ids.into_iter().next().unwrap_or_default()) }; map.insert(name.clone(), value); } // 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"> <AltImages images=alternative.images.clone() /> <h2>{alternative.name.clone()}</h2> <p class="alt-description">{alternative.description.clone()}</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 select_field_map = select_field_map.clone(); let field_prefix = field_prefix.clone(); let question_id = question_id.clone(); let alt_name = alternative.name.clone(); move |feature| { let field_map = field_map.clone(); let file_refs = file_refs.clone(); let select_field_map = select_field_map.clone(); let field_prefix = field_prefix.clone(); let question_id = question_id.clone(); let alt_name = alt_name.clone(); let resource = feature.resource.clone(); let feature_name = feature.name.clone(); let color = feature.color.clone(); let icon = feature.icon.clone(); view! { <div class="feature" style:--feature-accent=color> {icon.filter(|i| is_safe_icon_name(i)).map(|icon| { // Masked span, not <img>: an image can't take // currentColor, a mask painted by background-color can. let u = format!("https://api.iconify.design/{icon}.svg"); view! { <span class="feature-icon" style=format!("mask-image:url({u});-webkit-mask-image:url({u})") ></span> } })} <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() alternative=alt_name.clone() feature_name=feature_name.clone() transitions=spec.transitions.clone() pending_transitions=pending_transitions transition_batch=transition_batch /> } })} <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 select_field_map = select_field_map.clone(); let field_prefix = field_prefix.clone(); let question_id = question_id.clone(); let alt_name = alt_name.clone(); let feature_name = feature_name.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> }; let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new())); // A bound field watches a sibling and loads its own value from // the bind's resource whenever the sibling changes. An empty // sibling never fetches - and never clears an edit. if let Some(bind) = req .bind .clone() .filter(|_| !matches!(req.kind.as_str(), "select" | "file")) { let watched_select = select_field_map.get(&bind.field).copied(); let watched_text = field_map.get(&bind.field).copied(); let param_name = bind.param_name().to_string(); let q = question_id.clone(); let a = alt_name.clone(); let f = feature_name.clone(); let rname = req.name.clone(); let bound = Resource::new( move || { let value = watched_select .map(|s| s.get().first().cloned().unwrap_or_default()) .or_else(|| watched_text.map(|s| s.get())) .unwrap_or_default(); (q.clone(), a.clone(), f.clone(), rname.clone(), param_name.clone(), value) }, |(q, a, f, r, p, value)| async move { if value.is_empty() { return Ok(serde_json::Value::Null); } let params = std::collections::HashMap::from([(p, value)]); get_requirement_binding(q, a, f, r, params).await }, ); Effect::new(move |_| { if let Some(Ok(value)) = bound.get() { let text = match value { serde_json::Value::Null => return, serde_json::Value::String(s) => s, // apply_jq wraps every yield in an array - a // single string yield unwraps to itself. serde_json::Value::Array(items) if items.len() == 1 => { match &items[0] { serde_json::Value::String(s) => s.clone(), other => serde_json::to_string_pretty(other).unwrap_or_default(), } } other => serde_json::to_string_pretty(&other).unwrap_or_default(), }; sig.set(text); } }); } let is_bound = req.bind.is_some(); if req.kind == "select" { let select_sig = select_field_map .get(&req.name) .copied() .unwrap_or_else(|| RwSignal::new(Vec::new())); return view! { <div class="field"> {label_text} <SelectField question_id=question_id.clone() alternative=alt_name.clone() feature_name=feature_name.clone() requirement_name=req.name.clone() multiple=req.multiple id_field=req.id_field.clone() sig=select_sig /> </div> } .into_any(); } 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(); } if req.kind == "gesture" { let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); // A div, not a label: a label would forward // every click to the hidden input, and this // field is drawn on, not clicked into. #[cfg(feature = "hydrate")] { let relay = req.relay.clone().unwrap_or_default(); // Unlike prosekit's fire-and-forget mount, // the widget may own a live WebSocket - // yes.js's StoredValue + on_cleanup pattern // closes it on SPA navigation. let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> = StoredValue::new_local(None); Effect::new(move |_| { let (Some(container), Some(hidden)) = (container_ref.get(), hidden_ref.get()) else { return; }; if widget.with_value(|w| w.is_some()) { return; } widget.set_value(Some(gesture::mount_gesture( &container, &hidden, &relay, ))); }); on_cleanup(move || { widget.update_value(|opt| { if let Some(w) = opt.take() { w.stop(); } }); }); } return view! { <div class="field"> {label_text} <input id=field_id type="hidden" node_ref=hidden_ref prop:value=move || sig.get() on:input=move |ev| sig.set(event_target_value(&ev)) /> <div class="gesture-wrap" node_ref=container_ref></div> </div> } .into_any(); } if req.kind == "prosekit" { let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); #[cfg(feature = "hydrate")] { Effect::new(move |_| { let (Some(container), Some(hidden)) = (container_ref.get(), hidden_ref.get()) else { return; }; prosekit::mount_editor(&container, &hidden, &sig.get_untracked()); }); } return view! { <label class="field" for=label_for> {label_text} <input id=field_id type="hidden" node_ref=hidden_ref prop:value=move || sig.get() on:input=move |ev| sig.set(event_target_value(&ev)) /> <div class="prosekit-wrap" node_ref=container_ref></div> </label> } .into_any(); } 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() class:bound=is_bound 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> } .into_any() } } /> </div> {has_action .then(|| { view! { <div class="alt-cta"> <div class="alt-encouragements"> <For each=move || alternative.encouragements.clone() key=|e| e.clone() children=move |e| view! { <p class="alt-encouragement">{e}</p> } /> </div> {if is_gateway { let href = alternative.action.clone().unwrap_or_default(); view! { <a class="alt-submit" href=href>{button_label.clone()}</a> } .into_any() } else { view! { <form on:submit=on_submit> <button type="submit" class="alt-submit" disabled=move || { (!has_requirements && pending_transitions.get().is_empty()) || submit.pending().get() || transition_batch.pending().get() } > {button_label.clone()} </button> </form> } .into_any() }} </div> } })} </section> } .into_any() } /// 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, alternative: String, feature_name: String, transitions: Vec<Transition>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, transition_batch: ServerAction<TransitionAnswers>, ) -> impl IntoView { let data = Resource::new( { let question_id = question_id.clone(); let alternative = alternative.clone(); let feature_name = feature_name.clone(); move || (question_id.clone(), alternative.clone(), feature_name.clone()) }, // Empty params for now - forwarding sibling requirement values // as query params (for a resource parameterized by other form // fields) is deferred, see the resource-backed multi-select // requirement UI follow-up. |(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()), ); // The shared batch button lives on `AlternativeCard`, above every // feature on the page - any successful confirm refetches this // feature's own list, whether or not the batch actually touched // this particular resource (harmless extra refetch either way). Effect::new(move |_| { if let Some(Ok(())) = transition_batch.value().get() { data.refetch(); } }); view! { <div class="resource"> <Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }> {move || { let feature_name = feature_name.clone(); let transitions = transitions.clone(); data.get() .map(|res| match res { Ok(value) => { view! { <ResourceValue feature_name=feature_name transitions=transitions value=value pending_transitions=pending_transitions transition_batch=transition_batch /> } .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( feature_name: String, transitions: Vec<Transition>, value: serde_json::Value, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, transition_batch: ServerAction<TransitionAnswers>, ) -> 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 feature_name = feature_name.clone(); let transitions = transitions.clone(); move |answer: Answer| { view! { <AnswerRow feature_name=feature_name.clone() transitions=transitions.clone() answer=answer pending_transitions=pending_transitions transition_batch=transition_batch /> } .into_any() } } /> </div> } .into_any(); } if items.is_empty() { return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any(); } // A list of plain objects (e.g. a jq-shaped GiteaStarred/Url // resource) - render as cards rather than dumping raw JSON. // Generic on purpose, no content-declared "kind": `name`/`title` // becomes the heading (linked, if `url`/`html_url` is present), // `description` becomes the body text, and every other non-null // field becomes a small key/value chip - same idea as // `AnswerRow`'s `answer-fields`, just for data that isn't an // `Answer`. if items.iter().all(|i| i.is_object()) { let items = items.clone(); return view! { <div class="item-list"> <For each=move || items.clone() key=|item| item.to_string() children=|item: serde_json::Value| view! { <ItemCard item=item/> }.into_any() /> </div> } .into_any(); } } view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any() } /// Pulls the first present, non-null string field out of a JSON object /// matching one of `keys`, tried in order - shared between `ItemCard` /// and `SelectField`'s option rendering, since both need "guess a /// display label out of an otherwise-arbitrary shaped object" and /// should agree on the same guesses. fn text_field( obj: &serde_json::Map<String, serde_json::Value>, keys: &[&str], ) -> Option<String> { keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string) } /// One card in a generic (non-`Answer`) resource list - see /// `ResourceValue`. #[component] fn ItemCard(item: serde_json::Value) -> impl IntoView { let obj = item.as_object().cloned().unwrap_or_default(); let name = text_field(&obj, &["name", "title"]); let description = text_field(&obj, &["description"]); let url = text_field(&obj, &["url", "html_url"]); let known = ["name", "title", "description", "url", "html_url"]; let extra: Vec<(String, String)> = obj .iter() .filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null()) .map(|(k, v)| { let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); (k.clone(), text) }) .collect(); view! { <article class="item-card"> {(name.is_some() || url.is_some()) .then(|| { view! { <div class="item-card-title"> {match (name.clone(), url.clone()) { (Some(n), Some(u)) => { view! { <a href=u target="_blank" rel="noopener noreferrer"> {n} </a> } .into_any() } (Some(n), None) => view! { <span>{n}</span> }.into_any(), (None, Some(u)) => { view! { <a href=u.clone() target="_blank" rel="noopener noreferrer"> {u.clone()} </a> } .into_any() } (None, None) => ().into_any(), }} </div> } })} {description.map(|d| view! { <p class="item-card-description">{d}</p> })} {(!extra.is_empty()) .then(|| { view! { <div class="item-card-fields"> <For each=move || extra.clone() key=|(k, _)| k.clone() children=|(k, v)| { view! { <span class="item-card-field"> <span class="item-card-field-key">{k}</span> {v} </span> } .into_any() } /> </div> } })} </article> } } /// A `type: select` requirement's options, fetched from /// `Requirement.resource` (`get_requirement_options`) and rendered as /// toggleable buttons - single-select (radio-like: picking one clears /// any other) or multi-select (checkbox-like: each toggles /// independently), per `multiple`. `sig` holds the currently-selected /// id(s); the caller (`AlternativeCard`) reads it back at submit time. #[component] fn SelectField( question_id: String, alternative: String, feature_name: String, requirement_name: String, multiple: bool, /// Which field in each item is its stable id - `None` tries `_id` /// then `id`, matching `Requirement.id_field`'s own default. id_field: Option<String>, sig: RwSignal<Vec<String>>, ) -> impl IntoView { let data = Resource::new( { let question_id = question_id.clone(); let alternative = alternative.clone(); let feature_name = feature_name.clone(); let requirement_name = requirement_name.clone(); move || { ( question_id.clone(), alternative.clone(), feature_name.clone(), requirement_name.clone(), ) } }, |(q, a, f, r)| get_requirement_options(q, a, f, r, std::collections::HashMap::new()), ); view! { <div class="select-field"> <Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }> {move || { let id_field = id_field.clone(); data.get() .map(|res| match res { Ok(serde_json::Value::Array(items)) if !items.is_empty() => { view! { <div class="select-options"> <For each={ let items = items.clone(); move || items.clone() } key=|item| item.to_string() children={ let id_field = id_field.clone(); move |item: serde_json::Value| { let obj = item.as_object().cloned().unwrap_or_default(); let id = match &id_field { Some(f) => obj .get(f) .and_then(|v| v.as_str()) .map(str::to_string), None => text_field(&obj, &["_id", "id"]), } .unwrap_or_default(); let display = text_field(&obj, &["name", "title", "label"]) .unwrap_or_else(|| id.clone()); let id_for_selected = id.clone(); let id_for_click = id.clone(); view! { <button type="button" class="select-option" class:selected=move || { sig.get().contains(&id_for_selected) } on:click=move |_| { sig.update(|v| { if let Some(pos) = v .iter() .position(|x| x == &id_for_click) { v.remove(pos); } else if multiple { v.push(id_for_click.clone()); } else { *v = vec![id_for_click.clone()]; } }); } > {display} </button> } .into_any() } } /> </div> } .into_any() } Ok(_) => view! { <p class="resource-empty">"Nothing to pick from yet."</p> }.into_any(), Err(e) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(), }) }} </Suspense> </div> } } #[component] fn AnswerRow( feature_name: String, transitions: Vec<Transition>, answer: Answer, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, transition_batch: ServerAction<TransitionAnswers>, ) -> impl IntoView { // Only the transitions legal from this row's current state - two // declared transitions may share a target (open -> declined and // in_dialogue -> declined), each rendered only on rows actually in // its `from` state. let available: Vec<Transition> = transitions .iter() .filter(|t| t.from == answer.state) .cloned() .collect(); // "Decided" (dimmed) means this row has no legal move here. A // read-only listing (no transitions declared at all) keeps the old // rule - dim anything past `open` - since "no legal move" would // dim every row indiscriminately. let decided = if transitions.is_empty() { answer.state != crate::answers::OPEN_STATE } else { available.is_empty() }; 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 key = (feature_name.clone(), item_id.clone()); view! { <article class="answer-row" class:decided=move || decided> <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> } .into_any() } /> </div> <div class="answer-meta"> <span class="answer-state">{state}</span> <span class="answer-time">{format_ms(submitted_ms)}</span> </div> <Show when={ let available = available.clone(); move || !available.is_empty() }> { let key = key.clone(); let available = available.clone(); view! { <div class="answer-actions"> <div class="select-options"> <For each={ let available = available.clone(); move || available.clone() } key=|t| t.to.clone() children=move |t: Transition| { let key_for_class = key.clone(); let key_for_click = key.clone(); let to_for_class = t.to.clone(); let to_for_click = t.to.clone(); view! { <button type="button" class="select-option" class:selected=move || { pending_transitions.get().get(&key_for_class) == Some(&to_for_class) } disabled=move || transition_batch.pending().get() on:click=move |_| { let key = key_for_click.clone(); let to = to_for_click.clone(); pending_transitions.update(|m| { if m.get(&key) == Some(&to) { m.remove(&key); } else { m.insert(key, to); } }); } > {t.label.clone()} </button> } .into_any() } /> </div> </div> } } </Show> </article> } } /// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric- /// with-hyphens by convention - this is just a character allow-list /// before the value goes into a URL, not a lookup against Iconify's /// real prefix/name list. fn is_safe_icon_name(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':') } 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()) } // Rows re-rendered client-side (a post-confirm refetch) format // here, not on the server - raw milliseconds would show otherwise. #[cfg(all(not(feature = "ssr"), feature = "hydrate"))] { let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms as f64)); format!( "{:04}-{:02}-{:02} {:02}:{:02} UTC", date.get_utc_full_year(), date.get_utc_month() + 1, date.get_utc_date(), date.get_utc_hours(), date.get_utc_minutes(), ) } #[cfg(all(not(feature = "ssr"), not(feature = "hydrate")))] { 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> } } /// What a URL resolves to: the question (dynamic pages arrive with /// their segment value already substituted, see /// `content::resolve_question`) plus whether a `requires_chain` gate /// blocked it - `(target id, target name)` so the gate can point the /// visitor at where the required answer comes from. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Page { pub question: Question, pub chain_gate: Option<(String, String)>, } #[server(endpoint = "get_question")] pub async fn get_question( path: String, chain: Option<String>, ) -> Result<Option<Page>, ServerFnError> { use crate::content::{path_matches, resolve_question}; use crate::server::AppState; let state = expect_context::<AppState>(); let questions = state.questions.load(); let Some(question) = resolve_question(&questions, &path) else { return Ok(None); }; let mut chain_gate = None; if let Some(target) = &question.requires_chain { // The claimed lineage must verifiably end at the required // question - an unindexed or absent hash reads as unverified, // and the gate stays shut. Dynamic targets match any concrete // answer of theirs. let verified = match &chain { Some(hash) => crate::chain::lookup_node(&state.jetstream, hash) .await .is_some_and(|node| { node.question_id == *target || path_matches(target, &node.question_id).is_some() }), None => false, }; if !verified { let name = questions .get(target) .map(|q| q.name.clone()) .unwrap_or_default(); chain_gate = Some((target.clone(), name)); } } Ok(Some(Page { question, chain_gate, })) } /// The content repo's branding (`site.yaml`) - title, wordmark, hero /// kind. Defaults (= the historical uhhm look) when the repo declares /// nothing. #[server(endpoint = "get_site")] pub async fn get_site() -> Result<SiteConfig, ServerFnError> { use crate::server::AppState; let state = expect_context::<AppState>(); Ok(state.site.load().as_ref().clone()) } /// Every question the current visitor qualifies for, as (id, name) - /// the site nav's data. Context-dependent: an owner session sees the /// gated pages too, and `followup` pages only appear once the visitor /// carries an answer chain. #[server(endpoint = "list_qualifying_questions")] pub async fn list_qualifying_questions( has_chain: bool, ) -> Result<Vec<(String, String)>, ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; use crate::content::is_qualified; use crate::server::AppState; let state = expect_context::<AppState>(); 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()))?; let mut out: Vec<(String, String)> = state .questions .load() .values() .filter(|q| is_qualified(user.as_ref(), q)) .filter(|q| !q.is_followup() || has_chain) // A dynamic page has no URL of its own to link to. .filter(|q| !q.is_dynamic()) .map(|q| (q.id.clone(), q.name.clone())) .collect(); out.sort(); Ok(out) } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SubmitResult { pub next: Option<String>, pub chain_hash: String, } #[server(endpoint = "submit_answer")] 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>(); // resolve_question, not a plain map get: a dynamic page's concrete // path (what the client holds as its question id) resolves to the // pattern page it came from. let question = crate::content::resolve_question(&state.questions.load(), &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}")))?; // Index the node so requires_chain pages can verify lineage. // Best-effort: the NATS event above is the durable record. if let Err(e) = crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, timestamp_ms).await { tracing::error!("failed to index chain node: {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, &state.aggregates.load(), 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")) }