2026-07-29 19:38:40 +02:00
|
|
|
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};
|
|
|
|
|
|
2026-08-12 14:32:10 +02:00
|
|
|
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
2026-07-29 19:38:40 +02:00
|
|
|
use crate::auth::{current_user, User};
|
2026-08-06 08:52:29 +02:00
|
|
|
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
|
2026-08-06 11:57:15 +02:00
|
|
|
use crate::resource::{get_requirement_options, get_resource};
|
2026-07-29 19:38:40 +02:00
|
|
|
|
|
|
|
|
/// 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"/>
|
2026-08-06 12:11:17 +02:00
|
|
|
// 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"/>
|
2026-07-29 19:38:40 +02:00
|
|
|
<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")));
|
2026-08-05 18:44:28 +02:00
|
|
|
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
|
2026-07-29 19:38:40 +02:00
|
|
|
|
|
|
|
|
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()
|
2026-08-05 18:44:28 +02:00
|
|
|
query_email=query_email.get()
|
2026-07-29 19:38:40 +02:00
|
|
|
user=current
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
.into_any()
|
|
|
|
|
}
|
|
|
|
|
_ => view! { <NotFound/> }.into_any(),
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
</Suspense>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[component]
|
|
|
|
|
fn QuestionView(
|
|
|
|
|
question: Question,
|
|
|
|
|
parent_hash: Option<String>,
|
2026-08-05 18:44:28 +02:00
|
|
|
query_email: Option<String>,
|
2026-07-29 19:38:40 +02:00
|
|
|
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! {
|
2026-08-05 14:40:44 +02:00
|
|
|
<a
|
|
|
|
|
class="alt-submit"
|
|
|
|
|
href=format!("/auth/login?redirect={question_id}")
|
|
|
|
|
rel="external"
|
|
|
|
|
>
|
2026-07-29 19:38:40 +02:00
|
|
|
"Sign in"
|
|
|
|
|
</a>
|
|
|
|
|
}
|
|
|
|
|
.into_any()
|
|
|
|
|
}}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
.into_any();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 22:11:07 +02:00
|
|
|
// 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);
|
|
|
|
|
|
2026-08-12 23:25:06 +02:00
|
|
|
let has_chain = parent_hash.is_some();
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
view! {
|
2026-08-12 22:11:07 +02:00
|
|
|
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
|
2026-07-29 19:38:40 +02:00
|
|
|
<Hero
|
|
|
|
|
title=question.name.clone()
|
|
|
|
|
description=question.description.clone()
|
|
|
|
|
show_yes=question_id == "/"
|
|
|
|
|
/>
|
2026-08-12 23:25:06 +02:00
|
|
|
{question
|
|
|
|
|
.responsible
|
|
|
|
|
.clone()
|
|
|
|
|
.map(|r| {
|
|
|
|
|
view! { <ResponsibleNote responsible=r/> }
|
|
|
|
|
})}
|
2026-07-29 19:38:40 +02:00
|
|
|
<div class="alternatives">
|
|
|
|
|
<For
|
2026-08-05 18:44:28 +02:00
|
|
|
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<_>>()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
key=|a| a.name.clone()
|
2026-08-12 23:09:55 +02:00
|
|
|
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()
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-12 23:25:06 +02:00
|
|
|
<QuestionNav current_id=question_id has_chain=has_chain/>
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
.into_any()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 23:09:55 +02:00
|
|
|
/// 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]
|
2026-08-12 23:25:06 +02:00
|
|
|
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
|
|
|
|
|
let nav = Resource::new(move || has_chain, list_qualifying_questions);
|
2026-08-12 23:09:55 +02:00
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 20:48:27 +02:00
|
|
|
/// "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.
|
2026-08-06 08:52:29 +02:00
|
|
|
#[component]
|
2026-08-12 20:48:27 +02:00
|
|
|
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}"));
|
|
|
|
|
}
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|
2026-08-12 20:48:27 +02:00
|
|
|
#[cfg(not(feature = "hydrate"))]
|
|
|
|
|
let _ = &ev;
|
|
|
|
|
};
|
2026-08-06 08:52:29 +02:00
|
|
|
|
|
|
|
|
view! {
|
|
|
|
|
<p class="question-responsible">
|
2026-08-06 12:42:54 +02:00
|
|
|
"Asked by " {responsible.name.clone()}
|
|
|
|
|
<br/>
|
|
|
|
|
"— "
|
2026-08-12 20:48:27 +02:00
|
|
|
<a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble>
|
|
|
|
|
"contact them"
|
|
|
|
|
</a>
|
2026-08-06 08:52:29 +02:00
|
|
|
" if you get stuck."
|
|
|
|
|
</p>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 13:45:56 +02:00
|
|
|
// 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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
#[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
|
2026-08-05 14:52:08 +02:00
|
|
|
// 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();
|
2026-07-29 19:38:40 +02:00
|
|
|
#[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);
|
2026-08-05 14:52:08 +02:00
|
|
|
// 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.
|
2026-07-29 19:38:40 +02:00
|
|
|
Effect::new(move |_| {
|
2026-08-05 14:52:08 +02:00
|
|
|
if raster_ref.get().is_none() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
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>
|
2026-08-05 14:52:08 +02:00
|
|
|
<canvas id="rasterCanvas" node_ref=raster_ref></canvas>
|
2026-07-29 19:38:40 +02:00
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
<div class="hero-copy">
|
|
|
|
|
<a class="wordmark" href="/">
|
2026-08-06 13:59:03 +02:00
|
|
|
<img src="/wordmark.svg" alt=SITE_NAME/>
|
2026-07-29 19:38:40 +02:00
|
|
|
</a>
|
|
|
|
|
<h1>{title}</h1>
|
|
|
|
|
<p>{description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 22:11:07 +02:00
|
|
|
/// 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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
#[component]
|
|
|
|
|
fn AlternativeCard(
|
|
|
|
|
question_id: String,
|
|
|
|
|
alternative: Alternative,
|
|
|
|
|
parent_hash: Option<String>,
|
2026-08-05 18:44:28 +02:00
|
|
|
query_email: Option<String>,
|
2026-07-29 19:38:40 +02:00
|
|
|
) -> impl IntoView {
|
2026-08-05 18:44:28 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
let has_action = alternative.action.is_some();
|
2026-08-12 14:32:10 +02:00
|
|
|
let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty());
|
2026-08-12 23:09:55 +02:00
|
|
|
// 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()));
|
2026-08-12 14:32:10 +02:00
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-29 19:38:40 +02:00
|
|
|
|
2026-08-12 22:55:36 +02:00
|
|
|
// 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.
|
2026-07-29 19:38:40 +02:00
|
|
|
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();
|
2026-08-06 11:57:15 +02:00
|
|
|
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();
|
2026-07-29 19:38:40 +02:00
|
|
|
for feature in &alternative.features {
|
|
|
|
|
for req in &feature.requirements {
|
|
|
|
|
if req.kind == "file" {
|
2026-08-12 23:05:23 +02:00
|
|
|
file_refs.entry(req.name.clone()).or_default();
|
2026-08-06 11:57:15 +02:00
|
|
|
} 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);
|
2026-07-29 19:38:40 +02:00
|
|
|
} else {
|
|
|
|
|
field_map
|
|
|
|
|
.entry(req.name.clone())
|
|
|
|
|
.or_insert_with(|| RwSignal::new(String::new()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 22:55:36 +02:00
|
|
|
// 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.
|
2026-07-29 19:38:40 +02:00
|
|
|
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();
|
2026-08-06 11:57:15 +02:00
|
|
|
let select_field_map_for_submit = select_field_map.clone();
|
|
|
|
|
let select_multi_for_submit = select_multi.clone();
|
2026-08-12 14:32:10 +02:00
|
|
|
let question_id_for_transition = question_id.clone();
|
|
|
|
|
let alt_name_for_transition = alternative.name.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
|
|
|
|
ev.prevent_default();
|
2026-08-12 14:32:10 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
let field_map_for_submit = field_map_for_submit.clone();
|
|
|
|
|
let file_refs_for_submit = file_refs_for_submit.clone();
|
2026-08-06 11:57:15 +02:00
|
|
|
let select_field_map_for_submit = select_field_map_for_submit.clone();
|
|
|
|
|
let select_multi_for_submit = select_multi_for_submit.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
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()));
|
|
|
|
|
}
|
2026-08-06 11:57:15 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
// 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">
|
2026-08-12 22:11:07 +02:00
|
|
|
<AltImages images=alternative.images.clone() />
|
2026-07-29 19:38:40 +02:00
|
|
|
<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();
|
2026-08-06 11:57:15 +02:00
|
|
|
let select_field_map = select_field_map.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let field_prefix = field_prefix.clone();
|
|
|
|
|
let question_id = question_id.clone();
|
2026-08-05 15:04:02 +02:00
|
|
|
let alt_name = alternative.name.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
move |feature| {
|
|
|
|
|
let field_map = field_map.clone();
|
|
|
|
|
let file_refs = file_refs.clone();
|
2026-08-06 11:57:15 +02:00
|
|
|
let select_field_map = select_field_map.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let field_prefix = field_prefix.clone();
|
|
|
|
|
let question_id = question_id.clone();
|
2026-08-05 15:04:02 +02:00
|
|
|
let alt_name = alt_name.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let resource = feature.resource.clone();
|
|
|
|
|
let feature_name = feature.name.clone();
|
2026-08-12 17:34:07 +02:00
|
|
|
let color = feature.color.clone();
|
|
|
|
|
let icon = feature.icon.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
view! {
|
2026-08-12 17:34:07 +02:00
|
|
|
<div class="feature" style:--feature-accent=color>
|
2026-08-12 23:36:14 +02:00
|
|
|
{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>
|
|
|
|
|
}
|
2026-08-12 17:34:07 +02:00
|
|
|
})}
|
2026-07-29 19:38:40 +02:00
|
|
|
<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()
|
2026-08-05 15:04:02 +02:00
|
|
|
alternative=alt_name.clone()
|
2026-07-29 19:38:40 +02:00
|
|
|
feature_name=feature_name.clone()
|
|
|
|
|
transitions=spec.transitions.clone()
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions=pending_transitions
|
|
|
|
|
transition_batch=transition_batch
|
2026-07-29 19:38:40 +02:00
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
<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();
|
2026-08-06 11:57:15 +02:00
|
|
|
let select_field_map = select_field_map.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let field_prefix = field_prefix.clone();
|
2026-08-06 11:57:15 +02:00
|
|
|
let question_id = question_id.clone();
|
|
|
|
|
let alt_name = alt_name.clone();
|
|
|
|
|
let feature_name = feature_name.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
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>
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-05 13:45:56 +02:00
|
|
|
let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
|
|
|
|
|
|
2026-08-06 11:57:15 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 13:45:56 +02:00
|
|
|
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))
|
|
|
|
|
/>
|
2026-08-05 14:52:08 +02:00
|
|
|
<div class="prosekit-wrap" node_ref=container_ref></div>
|
2026-08-05 13:45:56 +02:00
|
|
|
</label>
|
|
|
|
|
}
|
|
|
|
|
.into_any();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
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>
|
|
|
|
|
}
|
2026-08-04 19:58:55 +02:00
|
|
|
.into_any()
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{has_action
|
|
|
|
|
.then(|| {
|
|
|
|
|
view! {
|
2026-08-06 12:11:17 +02:00
|
|
|
<div class="alt-cta">
|
2026-08-06 13:03:43 +02:00
|
|
|
<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>
|
2026-08-12 23:09:55 +02:00
|
|
|
{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()
|
|
|
|
|
}}
|
2026-08-06 12:11:17 +02:00
|
|
|
</div>
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
</section>
|
|
|
|
|
}
|
2026-08-05 18:44:28 +02:00
|
|
|
.into_any()
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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,
|
2026-08-05 15:04:02 +02:00
|
|
|
alternative: String,
|
2026-07-29 19:38:40 +02:00
|
|
|
feature_name: String,
|
|
|
|
|
transitions: Vec<Transition>,
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
|
|
|
|
transition_batch: ServerAction<TransitionAnswers>,
|
2026-07-29 19:38:40 +02:00
|
|
|
) -> impl IntoView {
|
|
|
|
|
let data = Resource::new(
|
|
|
|
|
{
|
|
|
|
|
let question_id = question_id.clone();
|
2026-08-05 15:04:02 +02:00
|
|
|
let alternative = alternative.clone();
|
2026-07-29 19:38:40 +02:00
|
|
|
let feature_name = feature_name.clone();
|
2026-08-05 15:04:02 +02:00
|
|
|
move || (question_id.clone(), alternative.clone(), feature_name.clone())
|
2026-07-29 19:38:40 +02:00
|
|
|
},
|
2026-08-06 08:52:29 +02:00
|
|
|
// 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()),
|
2026-07-29 19:38:40 +02:00
|
|
|
);
|
|
|
|
|
|
2026-08-12 14:32:10 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
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
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions=pending_transitions
|
|
|
|
|
transition_batch=transition_batch
|
2026-07-29 19:38:40 +02:00
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
.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,
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
|
|
|
|
transition_batch: ServerAction<TransitionAnswers>,
|
2026-07-29 19:38:40 +02:00
|
|
|
) -> 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
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions=pending_transitions
|
|
|
|
|
transition_batch=transition_batch
|
2026-07-29 19:38:40 +02:00
|
|
|
/>
|
|
|
|
|
}
|
2026-08-04 19:58:55 +02:00
|
|
|
.into_any()
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
.into_any();
|
|
|
|
|
}
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
|
|
|
|
|
}
|
2026-08-06 11:31:42 +02:00
|
|
|
// 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()
|
2026-08-06 11:34:18 +02:00
|
|
|
children=|item: serde_json::Value| view! { <ItemCard item=item/> }.into_any()
|
2026-08-06 11:31:42 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
.into_any();
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 11:57:15 +02:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 11:31:42 +02:00
|
|
|
/// 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();
|
2026-08-06 11:57:15 +02:00
|
|
|
let name = text_field(&obj, &["name", "title"]);
|
|
|
|
|
let description = text_field(&obj, &["description"]);
|
|
|
|
|
let url = text_field(&obj, &["url", "html_url"]);
|
2026-08-06 11:31:42 +02:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
}
|
2026-08-06 11:34:18 +02:00
|
|
|
.into_any()
|
2026-08-06 11:31:42 +02:00
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
</article>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 11:57:15 +02:00
|
|
|
/// 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>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
#[component]
|
|
|
|
|
fn AnswerRow(
|
|
|
|
|
feature_name: String,
|
|
|
|
|
transitions: Vec<Transition>,
|
|
|
|
|
answer: Answer,
|
2026-08-12 14:32:10 +02:00
|
|
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
|
|
|
|
transition_batch: ServerAction<TransitionAnswers>,
|
2026-07-29 19:38:40 +02:00
|
|
|
) -> impl IntoView {
|
2026-08-12 22:03:43 +02:00
|
|
|
// 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()
|
|
|
|
|
};
|
2026-07-29 19:38:40 +02:00
|
|
|
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();
|
2026-08-12 14:32:10 +02:00
|
|
|
let key = (feature_name.clone(), item_id.clone());
|
2026-07-29 19:38:40 +02:00
|
|
|
|
|
|
|
|
view! {
|
2026-08-12 22:03:43 +02:00
|
|
|
<article class="answer-row" class:decided=move || decided>
|
2026-07-29 19:38:40 +02:00
|
|
|
<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>
|
|
|
|
|
}
|
2026-08-04 19:58:55 +02:00
|
|
|
.into_any()
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="answer-meta">
|
|
|
|
|
<span class="answer-state">{state}</span>
|
|
|
|
|
<span class="answer-time">{format_ms(submitted_ms)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Show when={
|
2026-08-12 22:03:43 +02:00
|
|
|
let available = available.clone();
|
|
|
|
|
move || !available.is_empty()
|
2026-07-29 19:38:40 +02:00
|
|
|
}>
|
2026-08-12 14:32:10 +02:00
|
|
|
{
|
|
|
|
|
let key = key.clone();
|
2026-08-12 22:03:43 +02:00
|
|
|
let available = available.clone();
|
2026-08-12 14:32:10 +02:00
|
|
|
view! {
|
|
|
|
|
<div class="answer-actions">
|
|
|
|
|
<div class="select-options">
|
|
|
|
|
<For
|
|
|
|
|
each={
|
2026-08-12 22:03:43 +02:00
|
|
|
let available = available.clone();
|
|
|
|
|
move || available.clone()
|
2026-08-12 14:32:10 +02:00
|
|
|
}
|
|
|
|
|
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>
|
2026-08-06 12:42:54 +02:00
|
|
|
}
|
2026-08-12 14:32:10 +02:00
|
|
|
.into_any()
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
</Show>
|
|
|
|
|
</article>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 17:34:07 +02:00
|
|
|
/// 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 == ':')
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
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())
|
|
|
|
|
}
|
2026-08-12 23:05:23 +02:00
|
|
|
// 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")))]
|
2026-07-29 19:38:40 +02:00
|
|
|
{
|
|
|
|
|
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>();
|
2026-08-05 07:20:40 +02:00
|
|
|
Ok(state.questions.load().get(&path).cloned())
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-08-12 23:09:55 +02:00
|
|
|
/// Every question the current visitor qualifies for, as (id, name) -
|
|
|
|
|
/// the site nav's data. Context-dependent: an owner session sees the
|
2026-08-12 23:25:06 +02:00
|
|
|
/// gated pages too, and `followup` pages only appear once the visitor
|
|
|
|
|
/// carries an answer chain.
|
2026-08-12 23:09:55 +02:00
|
|
|
#[server]
|
2026-08-12 23:25:06 +02:00
|
|
|
pub async fn list_qualifying_questions(
|
|
|
|
|
has_chain: bool,
|
|
|
|
|
) -> Result<Vec<(String, String)>, ServerFnError> {
|
2026-08-12 23:09:55 +02:00
|
|
|
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))
|
2026-08-12 23:25:06 +02:00
|
|
|
.filter(|q| !q.followup || has_chain)
|
2026-08-12 23:09:55 +02:00
|
|
|
.map(|q| (q.id.clone(), q.name.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
out.sort();
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
#[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
|
2026-08-05 07:20:40 +02:00
|
|
|
.load()
|
2026-07-29 19:38:40 +02:00
|
|
|
.get(&question_id)
|
2026-08-05 07:20:40 +02:00
|
|
|
.cloned()
|
2026-07-29 19:38:40 +02:00
|
|
|
.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()))?;
|
2026-08-05 07:20:40 +02:00
|
|
|
if !is_qualified(user.as_ref(), &question) {
|
2026-07-29 19:38:40 +02:00
|
|
|
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,
|
2026-08-11 21:43:32 +02:00
|
|
|
&state.aggregates.load(),
|
2026-07-29 19:38:40 +02:00
|
|
|
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"))
|
|
|
|
|
}
|