Files
portal/src/app.rs
T
Bendik Aagaard LynghaugandClaude Sonnet 5 bdab23d0f6
Deploy / deploy (push) Successful in 35s
Use the real institutional logo for the header wordmark, not plain text
The header link was just styled "UHHM" text, unrelated to the actual
brand mark - swapped in the institutional logo (the three-arch
"building" mark, matching infrastructure/assets/brand/institutional-*.svg
and Gitea's own homepage logo) instead. Dark-stroke variant only -
portal is a single permanent dark theme, no light mode to switch for.
Same explicit-route treatment the favicon files needed (the /*any
wildcard route would otherwise swallow the request before the static
file handler gets a chance).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:59:03 +02:00

1443 lines
64 KiB
Rust

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, TransitionAnswer};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
use crate::resource::{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! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
// 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 <link>/<img> on Safari/iOS.
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Title text=SITE_NAME/>
<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(), get_question);
let user = Resource::new(|| (), |_| current_user());
view! {
<Suspense fallback=|| {
view! {
<main class="loading">
<p>"loading…"</p>
</main>
}
}>
{move || {
// Both read unconditionally (not nested inside the match
// below) so Suspense tracks both from the first pass,
// rather than only discovering `user` once `question`
// has already resolved.
let user_res = user.get();
let question_res = question.get();
question_res
.map(|res| match res {
Ok(Some(q)) => {
let current = user_res.and_then(|r| r.ok()).flatten();
view! {
<QuestionView
question=q
parent_hash=parent_hash.get()
query_email=query_email.get()
user=current
/>
}
.into_any()
}
_ => view! { <NotFound/> }.into_any(),
})
}}
</Suspense>
}
}
#[component]
fn QuestionView(
question: Question,
parent_hash: Option<String>,
query_email: Option<String>,
user: Option<User>,
) -> impl IntoView {
let question_id = question.id.clone();
// Generic: any question with `qualifies` set renders this same gate
// instead of its alternatives - "/review" isn't a special case, it's
// just a question that happens to have `qualifies` set.
if !is_qualified(user.as_ref(), &question) {
let signed_in = user.is_some();
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<div class="alternatives">
<section class="alt-card gate-card">
{if signed_in {
view! { <p>"This part of the site is for organizational owners — sign in with that account to take a look."</p> }
.into_any()
} else {
view! {
<a
class="alt-submit"
href=format!("/auth/login?redirect={question_id}")
rel="external"
>
"Sign in"
</a>
}
.into_any()
}}
</section>
</div>
}
.into_any();
}
let question_id_for_note = question_id.clone();
view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<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=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 question_id=question_id_for_note.clone() responsible=r/> }
})}
}
.into_any()
}
/// "Asked by X — contact them if you get stuck", plus a one-click
/// "report this question" action (see `report_question`) - mainly
/// meant for a question that reads as unhelpfully LLM-generated, so
/// whoever's responsible for it hears about it.
#[component]
fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoView {
let report = ServerAction::<ReportQuestion>::new();
let reported = RwSignal::new(false);
Effect::new(move |_| {
if report.value().get().is_some_and(|r| r.is_ok()) {
reported.set(true);
}
});
view! {
<p class="question-responsible">
"Asked by " {responsible.name.clone()}
<br/>
"— "
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
" if you get stuck."
{move || {
if reported.get() {
view! { <span class="question-reported">" Reported — thanks."</span> }
.into_any()
} else {
let question_id = question_id.clone();
view! {
<button
type="button"
class="question-report"
disabled=move || report.pending().get()
on:click=move |_| {
report
.dispatch(ReportQuestion {
question_id: question_id.clone(),
reason: None,
});
}
>
" Not helpful? Report this question."
</button>
}
.into_any()
}
}}
</p>
}
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
// 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,
);
}
}
#[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you
// pages, /review) get the plain dark header below. Starts the
// animation once the canvas elements exist, 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();
#[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;
}
instance.set_value(Some(yes::RasterizedYes::new()));
});
on_cleanup(move || {
instance.update_value(|opt| {
if let Some(inst) = opt.take() {
inst.stop();
}
});
});
}
view! {
<header class="hero" class:hero-yes=show_yes>
{show_yes
.then(|| {
view! {
<div class="hero-canvas" aria-hidden="true">
<canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas" node_ref=raster_ref></canvas>
</div>
}
})}
<div class="hero-copy">
<a class="wordmark" href="/">
<img src="/wordmark.svg" alt=SITE_NAME/>
</a>
<h1>{title}</h1>
<p>{description}</p>
</div>
</header>
}
}
#[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();
// File fields don't fit a live-typed RwSignal<String> - they get
// their own map of element refs, read (and uploaded) only at submit
// time, and are excluded from `field_map` below. `select` fields
// don't fit it either (a multi-select's value is a *set* of ids,
// not one string) - they get their own `Vec<String>`-signal map,
// plus a plain (non-reactive, content-derived) record of which
// select fields are `multiple`, read back at submit time to decide
// whether to emit a JSON array or a single string.
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();
for feature in &alternative.features {
for req in &feature.requirements {
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
} else 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 {
field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new()));
}
}
}
// Prefixes field DOM ids with the alternative too, not just the
// question - two alternatives on the same page (e.g. both asking for
// "email") would otherwise mint the same id twice, which is invalid
// HTML and makes <label for=…> pick the wrong input.
let field_prefix = format!(
"{question_id}-{}",
alternative.name.to_lowercase().replace(' ', "-")
);
let submit = ServerAction::<SubmitAnswer>::new();
let navigate = use_navigate();
let question_id_for_submit = question_id.clone();
let alt_name_for_submit = alternative.name.clone();
let parent_hash_for_submit = parent_hash.clone();
let field_map_for_submit = field_map.clone();
let file_refs_for_submit = file_refs.clone();
let select_field_map_for_submit = select_field_map.clone();
let select_multi_for_submit = select_multi.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let field_map_for_submit = field_map_for_submit.clone();
let file_refs_for_submit = file_refs_for_submit.clone();
let select_field_map_for_submit = select_field_map_for_submit.clone();
let select_multi_for_submit = select_multi_for_submit.clone();
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
map.insert(name.clone(), serde_json::Value::String(sig.get()));
}
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">
<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();
view! {
<div class="feature">
<Show when={
let name = feature.name.clone();
move || !name.is_empty()
}>
<h3>{feature.name.clone()}</h3>
</Show>
<Show when={
let description = feature.description.clone();
move || !description.is_empty()
}>
<p>{feature.description.clone()}</p>
</Show>
{resource.map(|spec| {
view! {
<ResourceFeature
question_id=question_id.clone()
alternative=alt_name.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
/>
}
})}
<For
each={
let reqs = feature.requirements.clone();
move || reqs.clone()
}
key=|r| r.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let 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()));
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 == "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()
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>
<form on:submit=on_submit>
<button
type="submit"
class="alt-submit"
disabled=move || submit.pending().get()
>
{button_label.clone()}
</button>
</form>
</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>,
) -> 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()),
);
view! {
<div class="resource">
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
{move || {
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
data.get()
.map(|res| match res {
Ok(value) => {
view! {
<ResourceValue
question_id=question_id
alternative=alternative
feature_name=feature_name
transitions=transitions
value=value
data=data
/>
}
.into_any()
}
Err(e) => {
view! { <p class="resource-error">{e.to_string()}</p> }.into_any()
}
})
}}
</Suspense>
</div>
}
}
/// Renders whatever `get_resource` returned, purely from its shape: an
/// array that parses as `answers::Answer` renders as a list (with
/// transition buttons, if any were declared); anything else renders as
/// a plain, read-only dump. No `kind`/render-mode tag anywhere - the
/// data decides.
#[component]
fn ResourceValue(
question_id: String,
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
value: serde_json::Value,
data: Resource<Result<serde_json::Value, ServerFnError>>,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
});
return view! {
<div class="answer-list">
<For
each=move || answers.clone()
key=|a| a.id.clone()
children={
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
move |answer: Answer| {
view! {
<AnswerRow
question_id=question_id.clone()
alternative=alternative.clone()
feature_name=feature_name.clone()
transitions=transitions.clone()
answer=answer
data=data
/>
}
.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(
question_id: String,
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
answer: Answer,
data: Resource<Result<serde_json::Value, ServerFnError>>,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
let responses = answer
.responses
.as_object()
.cloned()
.unwrap_or_default();
let submitted_ms = answer.submitted_ms;
let state = answer.state.clone();
let item_id = answer.id.clone();
let transition = ServerAction::<TransitionAnswer>::new();
// Toggle-select first (which transition, if any) - nothing is sent
// to the server until Confirm is pressed. Previously each button
// fired transition_answer immediately on click with no staging
// step and no visible confirmation once it landed (the list never
// refetched), so a click barely looked like it did anything.
let selected: RwSignal<Option<String>> = RwSignal::new(None);
Effect::new(move |_| {
if let Some(Ok(())) = transition.value().get() {
selected.set(None);
data.refetch();
}
});
view! {
<article class="answer-row" class:decided=move || !is_open>
<div class="answer-fields">
<For
each={
let responses = responses.clone();
move || responses.clone().into_iter().collect::<Vec<_>>()
}
key=|(k, _)| k.clone()
children=move |(key, value)| {
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<span class="answer-field-value">{text}</span>
</div>
}
.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 transitions = transitions.clone();
move || is_open && !transitions.is_empty()
}>
<div class="answer-actions">
<div class="select-options">
<For
each={
let transitions = transitions.clone();
move || transitions.clone()
}
key=|t| t.to.clone()
children=move |t: Transition| {
let to_for_selected = t.to.clone();
let to_for_click = t.to.clone();
view! {
<button
type="button"
class="select-option"
class:selected=move || {
selected.get().as_deref() == Some(to_for_selected.as_str())
}
on:click=move |_| {
selected
.update(|s| {
*s = if s.as_deref() == Some(to_for_click.as_str()) {
None
} else {
Some(to_for_click.clone())
};
});
}
>
{t.label.clone()}
</button>
}
.into_any()
}
/>
</div>
<button
type="button"
class="answer-confirm"
disabled=move || selected.get().is_none() || transition.pending().get()
on:click={
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
move |_| {
let Some(to) = selected.get() else { return };
transition.dispatch(TransitionAnswer {
question_id: question_id.clone(),
alternative: alternative.clone(),
feature_name: feature_name.clone(),
item_id: item_id.clone(),
to,
});
}
}
>
{move || if transition.pending().get() { "Confirming…" } else { "Confirm" }}
</button>
</div>
</Show>
</article>
}
}
fn format_ms(ms: i64) -> String {
#[cfg(feature = "ssr")]
{
use chrono::TimeZone;
chrono::Utc
.timestamp_millis_opt(ms)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| ms.to_string())
}
#[cfg(not(feature = "ssr"))]
{
ms.to_string()
}
}
#[component]
fn NotFound() -> impl IntoView {
view! {
<main class="not-found">
<h1>"Nothing here"</h1>
<a href="/">"back to the start"</a>
</main>
}
}
#[server]
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
Ok(state.questions.load().get(&path).cloned())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult {
pub next: Option<String>,
pub chain_hash: String,
}
#[server]
pub async fn submit_answer(
question_id: String,
alternative: String,
parent_hash: Option<String>,
responses_json: String,
) -> Result<SubmitResult, ServerFnError> {
use crate::answers::store_answer;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::content::is_qualified;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.load()
.get(&question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !is_qualified(user.as_ref(), &question) {
return Err(ServerFnError::new("not authorized for this question"));
}
let alt = question
.alternatives
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?;
let next = alt.action.clone();
let record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
let parent_hashes: Vec<String> = parent_hash.into_iter().collect();
let timestamp_ms = chrono::Utc::now().timestamp_millis();
let chain_hash = hash_node(&question_id, &parent_hashes, &responses, timestamp_ms);
emit_answer_submitted(
&state.nats,
&AnswerSubmitted {
chain_hash: chain_hash.clone(),
parent_hashes,
question_id: question_id.clone(),
alternative: alternative.clone(),
responses: responses.clone(),
timestamp_ms,
},
)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
// Best-effort: a bucket-write hiccup shouldn't fail a submission the
// NATS event has already recorded.
if let Some(bucket) = &record_as {
if let Err(e) = store_answer(
&state.jetstream,
bucket,
chain_hash.clone(),
&question_id,
&alternative,
&responses,
timestamp_ms,
)
.await
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
}
Ok(SubmitResult { next, chain_hash })
}
/// Fire-and-forget "this question wasn't helpful" report - a plain log
/// entry in the event store (`aggregate_type: "question_report"`, no
/// state machine, nothing to transition), reviewable via
/// `events::store::load_events(js, "question_report", question_id)` the
/// same way any other aggregate's history is. No auth, same as reading
/// a public question - a report is cheap, low-stakes signal, not
/// something worth gating behind a session.
#[server]
pub async fn report_question(
question_id: String,
reason: Option<String>,
) -> Result<(), ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let occurred_at_ms = chrono::Utc::now().timestamp_millis();
crate::events::store::append_event(
&state.jetstream,
"question_report",
&question_id,
"reported",
serde_json::json!({ "reason": reason }),
occurred_at_ms,
None,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
/// 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"))
}