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! {
}>
}
}
#[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! {
"loading…"
}
}>
{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 outranks App's
// SITE_NAME fallback only when content
// actually declares one.
{site_cfg
.title
.clone()
.map(|t| view! { })}
}
.into_any()
}
_ => view! { }.into_any(),
})
}}
}
}
#[component]
fn QuestionView(
question: Question,
chain_gate: Option<(String, String)>,
parent_hash: Option,
query_email: Option,
user: Option,
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! {
"This page follows from an answer you don't seem to carry yet."
{label}
}
.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! {
{if signed_in {
view! { "This part of the site is for organizational owners — sign in with that account to take a look."
}
.into_any()
} else {
view! {
"Sign in"
}
.into_any()
}}
}
.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! { })}
>()
}
}
key=|a| a.name.clone()
children={
let question_id = question_id.clone();
move |alt: Alternative| {
view! {
}
.into_any()
}
}
/>
{question
.responsible
.clone()
.map(|r| {
view! { }
})}
}
.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! {
{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! {
"Also worth asking"
{name} }.into_any()
}
/>
})
})
}}
}
}
/// "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::(&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! {
"Asked by " {responsible.name.clone()} " — "
"contact them"
" if you get stuck."
}
}
// 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 ` ` 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 = NodeRef::new();
let gesture_ref: NodeRef = 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, 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 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::()
}
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, 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! {
{show_yes
.then(|| {
view! {
// 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.
}
})}
{show_gesture
.then(|| {
view! {
}
})}
{title}
{description}
}
}
/// 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) -> impl IntoView {
match images.len() {
0 => ().into_any(),
1 => view! { }.into_any(),
_ => view! {
}.into_any()
/>
}
.into_any(),
}
}
#[component]
fn AlternativeCard(
question_id: String,
alternative: Alternative,
parent_hash: Option,
query_email: Option,
) -> 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::::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! {
{alternative.name.clone()}
{alternative.description.clone()}
{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! {
{label.clone()}
}
.into_any(),
Some(Ok(())) => view! { "Done."
}.into_any(),
Some(Err(e)) => view! { {e.to_string()}
}.into_any(),
}
}}
}
.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> =
RwSignal::new(std::collections::HashMap::new());
let transition_batch = ServerAction::::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> =
std::collections::HashMap::new();
let mut file_refs: std::collections::HashMap> =
std::collections::HashMap::new();
let mut select_field_map: std::collections::HashMap>> =
std::collections::HashMap::new();
let mut select_multi: std::collections::HashMap = 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 = 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 pick the wrong input.
let field_prefix = format!(
"{question_id}-{}",
alternative.name.to_lowercase().replace(' ', "-")
);
let submit = ServerAction::::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! {
{alternative.name.clone()}
{alternative.description.clone()}
{icon.filter(|i| is_safe_icon_name(i)).map(|icon| {
// Masked span, not : an image can't take
// currentColor, a mask painted by background-color can.
let u = format!("https://api.iconify.design/{icon}.svg");
view! {
}
})}
{feature.name.clone()}
{feature.description.clone()}
{resource.map(|spec| {
view! {
}
})}
{req.display_label()}
{(!req.optional).then(|| view! { "*" })}
};
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! {
{label_text}
}
.into_any();
}
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
return view! {
{label_text}
}
.into_any();
}
if req.kind == "gesture" {
let container_ref: NodeRef = NodeRef::new();
let hidden_ref: NodeRef = 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, 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! {
}
.into_any();
}
if req.kind == "prosekit" {
let container_ref: NodeRef = NodeRef::new();
let hidden_ref: NodeRef = 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_text}
}
.into_any();
}
view! {
{label_text}
}
}
}
>
{
let field_id = field_id.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let required = !req.optional;
view! {
}
}
}
.into_any()
}
}
/>
}
.into_any()
}
}
/>
{has_action
.then(|| {
view! {
{e} }
/>
{if is_gateway {
let href = alternative.action.clone().unwrap_or_default();
view! {
{button_label.clone()}
}
.into_any()
} else {
view! {
}
.into_any()
}}
}
})}
}
.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,
pending_transitions: RwSignal>,
transition_batch: ServerAction,
) -> 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! {
"loading…" }>
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
data.get()
.map(|res| match res {
Ok(value) => {
view! {
}
.into_any()
}
Err(e) => {
view! { {e.to_string()}
}.into_any()
}
})
}}
}
}
/// 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,
value: serde_json::Value,
pending_transitions: RwSignal>,
transition_batch: ServerAction,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::>(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! {
}
.into_any()
}
}
/>
}
.into_any();
}
if items.is_empty() {
return view! { "Nothing here yet."
}.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! {
}.into_any()
/>
}
.into_any();
}
}
view! { {value.to_string()} }.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,
keys: &[&str],
) -> Option {
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! {
{(name.is_some() || url.is_some())
.then(|| {
view! {
{match (name.clone(), url.clone()) {
(Some(n), Some(u)) => {
view! {
{n}
}
.into_any()
}
(Some(n), None) => view! {
{n} }.into_any(),
(None, Some(u)) => {
view! {
{u.clone()}
}
.into_any()
}
(None, None) => ().into_any(),
}}
}
})}
{description.map(|d| view! { {d}
})}
{(!extra.is_empty())
.then(|| {
view! {
{k}
{v}
}
.into_any()
}
/>
}
})}
}
}
/// 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,
sig: RwSignal>,
) -> 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! {
"loading…" }>
{move || {
let id_field = id_field.clone();
data.get()
.map(|res| match res {
Ok(serde_json::Value::Array(items)) if !items.is_empty() => {
view! {
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! {
{display}
}
.into_any()
}
}
/>
}
.into_any()
}
Ok(_) => view! { "Nothing to pick from yet."
}.into_any(),
Err(e) => view! { {e.to_string()}
}.into_any(),
})
}}
}
}
#[component]
fn AnswerRow(
feature_name: String,
transitions: Vec,
answer: Answer,
pending_transitions: RwSignal>,
transition_batch: ServerAction,
) -> 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 = 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! {
>()
}
key=|(k, _)| k.clone()
children=move |(key, value)| {
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
view! {
{key}
{text}
}
.into_any()
}
/>
{state}
{format_ms(submitted_ms)}
{
let key = key.clone();
let available = available.clone();
view! {
{t.label.clone()}
}
.into_any()
}
/>
}
}
}
}
/// 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! {
"Nothing here"
"back to the start"
}
}
/// 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,
) -> Result, ServerFnError> {
use crate::content::{path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::();
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 {
use crate::server::AppState;
let state = expect_context::();
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, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::server::AppState;
let state = expect_context::();
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::(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,
pub chain_hash: String,
}
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
alternative: String,
parent_hash: Option,
responses_json: String,
) -> Result {
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::();
// 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::(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 = 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 {
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"))
}