Files
portal/src/app.rs
T

3297 lines
158 KiB
Rust
Raw Normal View History

use leptos::prelude::*;
2026-09-01 22:12:20 +02:00
use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
path, NavigateOptions,
};
use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
2026-09-30 06:47:36 +02:00
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
use crate::i18n::t;
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's
/// working title, not necessarily what any given deployment is called.
/// Baked in at *build* time (`option_env!`, not `std::env::var`) so the
/// ssr and hydrate targets - compiled separately - end up with the
/// identical value without a runtime round-trip or context-passing
/// between them; set `SITE_NAME` for both `cargo leptos` invocations
/// (ssr and hydrate) to change it, e.g. `SITE_NAME=uhhm`. CSS uppercases
/// the wordmark for display regardless of the source casing here.
const SITE_NAME: &str = match option_env!("SITE_NAME") {
Some(name) => name,
None => "portal",
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!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"/>
// Fonts hide behind the stylesheet: the browser must
// fetch and parse CSS before it discovers them. The
// latin subset is on every page's critical path, so
// announce it up front; latin-ext and vietnamese stay
// unicode-range-gated and load only when script needs
// them - preloading those would tax everyone for a few.
// (Font preloads require crossorigin even same-origin.)
<link
rel="preload"
href="/fonts/quicksand-semibold-latin.woff2"
r#as="font"
type="font/woff2"
crossorigin="anonymous"
/>
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
// Before the router, so it sees a link's click first.
<script src="/page-motion.js" defer=true></script>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
// ONE site resource and ONE <Title> for the whole app, both living
// outside the router. Two dueling Title components (a static
// SITE_NAME fallback here + a per-page override) turned every SPA
// navigation into a remount race that the compile-time fallback
// kept winning - redoal.com's tab flipped to "uhhm" on the first
// client-side nav. App never remounts, so this Title never
// unmounts; the Suspense makes SSR await the resolved title so the
// served <head> is right too. Pages read the same resource via
// context instead of fetching their own copy.
let site = Resource::new(|| (), |_| get_site());
provide_context(site);
// The chrome's language, from site.yaml (default "en"); content
// speaks for itself. Components read this via context for t().
let lang = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.lang)
.unwrap_or_else(|| "en".to_string())
});
provide_context(Lang(lang));
// A content-shipped stylesheet (site.yaml `stylesheet:`) layered
// after the default one - leptos_meta appends it at the MetaTags
// slot, which the shell places after HashedStylesheet, so content
// rules win at equal specificity.
let custom_css = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.stylesheet)
.map(|p| format!("/site/{p}"))
});
// The wordmark paints in the hero on first view; announcing it in
// the head starts the fetch during hydration instead of after. On
// redoal.com it lives on the content host, so a preconnect opens
// that TLS session while the preload is still queued.
let wordmark_href = Memo::new(move |_| {
site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark)
});
let wordmark_origin = Memo::new(move |_| {
wordmark_href.get().and_then(|w| {
let rest = w.strip_prefix("https://")?;
Some(format!("https://{}", rest.split('/').next()?))
})
});
2026-09-01 22:12:20 +02:00
// A content-shipped favicon overrides the built-in one; injected
// into the head after the static defaults, so it wins.
let favicon = Memo::new(move |_| {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.favicon)
.map(|p| format!("/site/{p}"))
});
view! {
<Html attr:lang=move || lang.get()/>
{move || wordmark_origin.get().map(|href| view! { <Link rel="preconnect" href=href crossorigin="anonymous"/> })}
{move || wordmark_href.get().map(|href| view! { <Link rel="preload" as_="image" href=href/> })}
{move || custom_css.get().map(|href| view! { <Stylesheet id="site-custom" href=href/> })}
2026-09-01 22:12:20 +02:00
{move || favicon.get().map(|href| view! { <Link rel="icon" href=href/> })}
<Suspense fallback=|| ()>
{move || {
site.get()
.map(|res| {
let title = res
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| SITE_NAME.to_string());
view! { <Title text=title/> }
})
}}
</Suspense>
<Router>
<PortalShell/>
</Router>
}
}
/// The site's chrome language as a context signal - see App.
#[derive(Clone, Copy)]
pub struct Lang(pub Memo<String>);
/// The current chrome language, for `t()` calls inside views.
fn lang() -> Memo<String> {
expect_context::<Lang>().0
}
/// Every server-fn resource the pages read, created exactly once for
/// the app's lifetime and handed down via context. Wrapper structs
/// because a bare `Resource<T>` context is claimed by whoever provides
/// that T last.
#[derive(Clone, Copy)]
struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
#[derive(Clone, Copy)]
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource<Result<Vec<(String, String)>, ServerFnError>>);
#[derive(Clone, Copy)]
struct AnnounceRes(Resource<Result<Vec<Announcement>, ServerFnError>>);
/// One live announced page (`Question.event`), pre-formatted on the
/// server so both renders agree and no client clock is involved.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Announcement {
pub id: String,
pub name: String,
/// "Sat 12 Sep, 18:00"
pub when: String,
/// "in 3 days" / "in 4 h" / "now"
pub relative: String,
pub place: Option<String>,
}
/// Owns the app's data resources, above the routes and reactive on the
/// location instead of recreated per page. Route components creating
/// their own resources broke on the first client-side navigation: the
/// remounted component's fresh Resource consumed a stale SSR hydration
/// buffer instead of fetching - concretely, the nav list
/// `[[id, name], ..]` deserialized as a `Page` (serde fills a struct
/// from a sequence in field order), rendering the landing question
/// gated behind its own nav entry. Stable resources + context makes
/// that class of misalignment impossible: navigation only changes a
/// key, and a key change always refetches.
#[component]
fn PortalShell() -> 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 chain = Memo::new(move |_| query.with(|q| q.get("chain")));
let question = Resource::new(
move || (path.get(), chain.get()),
|(path, chain)| get_question(path, chain),
);
let user = Resource::new(|| (), |_| current_user());
let nav = Resource::new(
move || (path.get(), chain.get()),
|(path, chain)| list_qualifying_questions(path, chain),
);
let announcements = Resource::new(|| (), |_| list_announcements());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
provide_context(NavRes(nav));
provide_context(AnnounceRes(announcements));
view! {
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let query = use_query_map();
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
// All shared, app-lifetime resources (see PortalShell / App) -
// never created per navigation.
let QuestionRes(question) = expect_context();
let UserRes(user) = expect_context();
let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>();
view! {
// Nothing is written here on purpose. This fallback is the
// whole served page until the question resolves - no header,
// no wordmark - so any word in it is the first and only thing
// a visitor, or a crawler, reads about the site. It used to
// say "loading…", which is true and is not about them. The
// space is held so the page does not jump when the words
// arrive, and `aria-busy` says what is happening to anyone
// whose reader would otherwise find an empty document.
<Suspense fallback=|| {
view! { <main class="loading" aria-busy="true"></main> }
}>
{move || {
// Both read unconditionally (not nested inside the match
// below) so Suspense tracks both from the first pass,
// rather than only discovering `user` once `question`
// has already resolved.
let user_res = user.get();
let question_res = question.get();
let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default();
question_res
.map(|res| match res {
Ok(Some(page)) => {
let current = user_res.and_then(|r| r.ok()).flatten();
view! {
<QuestionView
question=page.question
chain_gate=page.chain_gate
parent_hash=parent_hash.get()
query_email=query_email.get()
user=current
site=site_cfg
/>
}
.into_any()
}
_ => view! { <NotFound/> }.into_any(),
})
}}
</Suspense>
}
}
#[component]
fn QuestionView(
question: Question,
chain_gate: Option<(String, String)>,
parent_hash: Option<String>,
query_email: Option<String>,
user: Option<User>,
site: SiteConfig,
) -> impl IntoView {
let question_id = question.id.clone();
// The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to
// the required question renders a pointer there instead of its
// alternatives.
if let Some((target, target_name)) = chain_gate {
let label = if target_name.is_empty() {
target.clone()
} else {
target_name
};
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
current_id=question_id.clone()
site=site.clone()
/>
<div class="alternatives">
<section class="alt-card gate-card">
<p>{move || t(&lang().get(), "follows_answer")}</p>
<a class="alt-submit" href=target>
{label}
</a>
</section>
</div>
2026-08-25 17:05:09 +02:00
// A gate is never a dead end: the same nav as every page.
<QuestionNav current_id=question_id/>
}
.into_any();
}
// Generic: any question with `qualifies` set renders this same gate
// instead of its alternatives - "/review" isn't a special case, it's
// just a question that happens to have `qualifies` set.
if !is_qualified(user.as_ref(), &question) {
let signed_in = user.is_some();
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
current_id=question_id.clone()
site=site.clone()
/>
<div class="alternatives">
<section class="alt-card gate-card">
{if signed_in {
view! { <p>{move || t(&lang().get(), "owners_only")}</p> }
.into_any()
} else {
view! {
<a
class="alt-submit"
href=format!("/auth/login?redirect={question_id}")
rel="external"
>
{move || t(&lang().get(), "sign_in")}
</a>
}
.into_any()
}}
</section>
</div>
<QuestionNav current_id=question_id/>
}
.into_any();
}
// Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck.
let needs_swiper = question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1);
// MapLibre is vendored and large, so it loads only where a field
// actually asks for a place, and only where this instance can
// serve tiles at all.
// The page's place, in the form a location field opens on.
let place_value = question.place.as_ref().map(crate::content::Place::as_value);
let needs_map = site.maps
&& question.alternatives.iter().any(|a| {
a.features.iter().any(|f| f.requirements.iter().any(|r| r.kind == "location"))
});
view! {
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
{needs_map
.then(|| {
view! {
<leptos_meta::Stylesheet href="/maplibre-gl.css" />
<leptos_meta::Script src="/maplibre-gl.js" />
}
})}
<Hero
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
site=site.clone()
current_id=question_id.clone()
user=user.clone()
slideshow=question.slideshow.clone()
/>
// A page that is about somewhere says where, under the
// question: a still from this origin, and the place in words
// for anyone who cannot see it.
{question
.place
.clone()
.filter(|_| site.maps)
.map(|place| {
let name = place.name.clone().unwrap_or_else(|| format!("{}, {}", place.lat, place.lon));
view! {
<figure class="question-place">
<img
class="map-still"
src=crate::maps::still_url(place.lat, place.lon, place.zoom, 640, 280)
alt=format!("Map of {name}")
loading="lazy"
/>
<figcaption>{name}</figcaption>
</figure>
}
})}
<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()
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()
place=place_value.clone().unwrap_or_default()
2026-08-12 23:09:55 +02:00
/>
}
.into_any()
}
}
/>
</div>
// The person the page names as responsible, signed in, may
// suggest a change to it; the server checks it is them.
{question
.responsible
.as_ref()
2026-09-30 08:17:27 +02:00
.filter(|r| is_responsible(user.as_ref(), r))
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
{question
.responsible
.clone()
.map(|r| {
view! { <ResponsibleNote responsible=r/> }
})}
<QuestionNav current_id=question_id/>
}
.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).
/// Where the link at the foot of a page takes a visitor: the nearest page below
2026-09-29 07:05:39 +02:00
/// the current one in the tree that they may open, found by taking the
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
/// and the front door when none of those is a page. `None` on the front
/// door itself, which has nowhere further down to go. It is always a
/// valid way on, so a visitor who went up a branch whose context turned
/// out not to apply is never left stranded.
pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> {
let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/');
if path.is_empty() {
return None;
}
let mut at = path;
while let Some(i) = at.rfind('/') {
at = &at[..i];
let candidate = if at.is_empty() { "/" } else { at };
if let Some(page) = pages.iter().find(|(id, _)| id == candidate) {
return Some(page.clone());
}
if at.is_empty() {
break;
}
}
Some(("/".to_string(), String::new()))
}
/// How the way down names where it goes: "the start" for the front
/// door, and otherwise the page's own question, quoted - a page's
/// name is the question it asks, which reads as a destination only in
/// quotation marks.
fn down_the_tree_label(lang: &str, href: &str, name: &str) -> String {
let to = if href == "/" || name.is_empty() {
t(lang, "the_start").to_string()
} else if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
format!("«{name}»")
} else {
format!("\u{201c}{name}\u{201d}")
};
format!("{}{to}", t(lang, "down_to"))
}
2026-08-12 23:09:55 +02:00
#[component]
fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context();
2026-09-29 07:05:39 +02:00
let location = leptos_router::hooks::use_location();
2026-08-12 23:09:55 +02:00
view! {
// The way down the tree, at the foot of the page, in words:
// where a reader who has read to the end looks for where to go
// next. It replaces a "back" alternative on every page that had
// one.
2026-09-29 07:05:39 +02:00
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<nav class="down-the-tree-foot">
<a href=href>{format!("← {label}")}</a>
</nav>
}
})
}}
</Suspense>
2026-08-12 23:09:55 +02:00
<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">{move || t(&lang().get(), "also_worth_asking")}</span>
2026-08-12 23:09:55 +02:00
<For
each=move || others.clone()
key=|(id, _)| id.clone()
children=|(id, name): (String, String)| {
view! { <a href=id>{name}</a> }.into_any()
}
/>
</nav>
})
})
}}
</Suspense>
}
}
/// Live announced pages, as a strip at the top of the header - the one
/// place a page gets to claim attention before the question. Empty
/// (and unrendered) when nothing is announced.
#[component]
fn Announcements(current_id: String) -> impl IntoView {
let AnnounceRes(announcements) = expect_context();
view! {
<Suspense fallback=|| ()>
{move || {
let current_id = current_id.clone();
announcements.get().and_then(|res| res.ok()).map(|items| {
(!items.is_empty()).then(|| view! {
<nav class="announce" aria-label=move || t(&lang().get(), "announcements")>
<For
each=move || items.clone()
key=|a| a.id.clone()
children=move |a: Announcement| {
let current = a.id == current_id;
view! {
<a href=a.id.clone() class="announce-item" aria-current=current.then_some("page")>
<span class="announce-name">{a.name}</span>
<span class="announce-when">
{a.when}
{a.place.map(|p| format!(" · {p}"))}
</span>
<span class="announce-relative">{a.relative}</span>
</a>
}
.into_any()
}
/>
</nav>
})
})
}}
</Suspense>
}
}
/// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` -
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
2026-09-30 08:17:27 +02:00
/// Whether the signed-in person answers for a page: their Kanidm
/// address equals the page's `responsible.contact` (`mailto:` off,
/// case ignored), or they are in the group that holds it.
pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool {
let Some(user) = user else { return false };
if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) {
return true;
}
let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
user.email.as_deref().is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
}
/// One suggestion as the person who made it sees it: what they wrote
/// and where it stands.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MySuggestion {
pub id: String,
pub text: String,
pub state: String,
pub submitted_ms: i64,
}
/// The translation key for where a suggestion stands. A state the
/// content added to the bucket's graph has no words here, and shows as
/// itself.
fn suggestion_state_key(state: &str) -> Option<&'static str> {
match state {
"open" => Some("suggestion_open"),
"discussed" => Some("suggestion_discussed"),
"done" => Some("suggestion_done"),
"declined" => Some("suggestion_declined"),
_ => None,
}
}
/// A change suggested by the person responsible for the page: one
/// field, which becomes a case for whoever keeps the site, and below
/// it what became of the ones sent before. Shown only to them.
#[component]
fn SuggestEdit(question_id: String) -> impl IntoView {
let send = ServerAction::<SuggestPageEdit>::new();
let text = NodeRef::<leptos::html::Textarea>::new();
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
let mine = {
let question_id = question_id.clone();
Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone()))
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let value = text.get().map(|t| t.value()).unwrap_or_default();
if !value.trim().is_empty() {
send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value });
}
};
view! {
<section class="suggest-edit">
<h2>{move || t(&lang().get(), "suggest_edit")}</h2>
<Show
when=sent
fallback=move || view! {
<p>{move || t(&lang().get(), "suggest_edit_help")}</p>
<form on:submit=on_submit.clone()>
<textarea node_ref=text rows="4" required=true></textarea>
<button type="submit" disabled=move || send.pending().get()>{move || t(&lang().get(), "suggest_edit_send")}</button>
</form>
}
>
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
</Show>
<Transition fallback=|| ()>
{move || {
mine.get().and_then(|res| res.ok()).filter(|items| !items.is_empty()).map(|items| view! {
<h3>{move || t(&lang().get(), "suggestions_yours")}</h3>
<ul class="suggest-edit-mine">
<For
each=move || items.clone()
key=|s| (s.id.clone(), s.state.clone())
children=|s: MySuggestion| {
let state = s.state.clone();
let label = move || suggestion_state_key(&state).map(|key| t(&lang().get(), key).to_string()).unwrap_or_else(|| state.clone());
view! {
<li>
<span class="suggest-edit-state" data-state=s.state.clone()>{label}</span>
<span class="suggest-edit-text">{s.text}</span>
</li>
}
.into_any()
}
/>
</ul>
})
}}
</Transition>
</section>
}
}
#[component]
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible
.contact
.split_once('@')
.map(|(u, d)| (u.to_string(), d.to_string()))
.unwrap_or((responsible.contact.clone(), String::new()));
// Zero captures (reads data-user/data-domain off the target element
// itself, not from the closure's environment) - Copy, so the same
// value works for both `on:` attributes below without cloning.
let assemble = move |ev: leptos::ev::MouseEvent| {
#[cfg(feature = "hydrate")]
{
let target = event_target::<web_sys::HtmlAnchorElement>(&ev);
if let (Some(user), Some(domain)) =
(target.get_attribute("data-user"), target.get_attribute("data-domain"))
{
let _ = target.set_attribute("href", &format!("mailto:{user}@{domain}"));
}
}
#[cfg(not(feature = "hydrate"))]
let _ = &ev;
};
view! {
<small class="question-responsible">
{move || t(&lang().get(), "asked_by")} {responsible.name.clone()} " — "
<a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble>
{move || t(&lang().get(), "contact_them")}
</a>
{move || t(&lang().get(), "if_stuck")}
</small>
}
}
// Same idiomatic-typed-module approach as `yes` above, for the
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
// project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative
// to the crate root, not the `public/` static-asset dir, same as
// `yes.js` below). `mount_editor` hands the editor a container node
// plus the paired
// hidden `<input>` it mirrors its HTML into and fires real `input`
// events on - see `mount_editor`'s call site in `AlternativeCard`, and
// the JS module itself for why a hidden-input bridge rather than a
// bespoke Rust<->JS value channel.
#[cfg(feature = "hydrate")]
mod prosekit {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/prosekit-editor.js")]
extern "C" {
#[wasm_bindgen(js_name = mountEditor)]
pub fn mount_editor(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
initial: &str,
);
}
}
// The `type: gesture` drawing widget (`gesture.js`, crate root):
// prosekit's hidden-input bridge for the value, yes.js's typed-handle
// lifecycle for cleanup - it may own a live WebSocket to a
// redoal-relay, which SPA navigation must close (`stop()`).
#[cfg(feature = "hydrate")]
#[cfg(feature = "hydrate")]
mod voice {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/voice.js")]
extern "C" {
pub type VoiceWidget;
#[wasm_bindgen(js_name = mountVoice)]
pub fn mount_voice(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
key: &str,
) -> VoiceWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &VoiceWidget);
}
}
// The `type: location` map (`map.js`, crate root): the same
// hidden-input bridge, and a handle whose `stop()` tears the map down
// on SPA navigation - MapLibre keeps a WebGL context and its own
// listeners, and a page that has moved on must not keep either.
#[cfg(feature = "hydrate")]
mod map {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/map.js")]
extern "C" {
pub type MapWidget;
#[wasm_bindgen(js_name = mountMap)]
pub fn mount_map(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
initial: &str,
) -> MapWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &MapWidget);
}
}
#[cfg(feature = "hydrate")]
mod gesture {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/gesture.js")]
extern "C" {
pub type GestureWidget;
#[wasm_bindgen(js_name = mountGesture)]
pub fn mount_gesture(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &GestureWidget);
}
}
#[component]
fn Hero(
title: String,
description: String,
landing: bool,
site: SiteConfig,
current_id: String,
#[prop(optional_no_strip)] user: Option<User>,
#[prop(optional_no_strip)] slideshow: Option<crate::content::Slideshow>,
) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
// `hero: {kind: module, module: hero.js}` names a JavaScript module
// (served same-origin at /site/<path>) exporting
// `mount(container) -> handle` with `handle.stop()`. Portal knows
// nothing about what it draws.
let module = if landing && site.hero.kind == "module" {
site.hero.module.clone()
} else {
None
};
let has_module = module.is_some();
// Light theme can invert a white-stroke wordmark to ink. Default:
// only the built-in house mark inverts; a content-shipped logo
// keeps its colours unless site.yaml opts in with wordmark_invert.
let house_wordmark = site.wordmark_invert.unwrap_or(site.wordmark.is_none());
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
// Clamped server-side too, but belt and braces for the inline style.
let wordmark_style = site
.wordmark_height
.filter(|h| (0.5..=6.0).contains(h))
.map(|h| format!("height: {h}rem"))
.unwrap_or_default();
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
let piece_ref: NodeRef<leptos::html::Div> = NodeRef::new();
#[cfg(feature = "hydrate")]
if has_module {
use wasm_bindgen::JsCast;
// Handle = whatever the module's mount() returned; kept as a
// raw JsValue so the contract stays "has a stop()", nothing
// typed on the Rust side.
let handle: StoredValue<Option<wasm_bindgen::JsValue>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let Some(container) = piece_ref.get() else {
return;
};
if handle.with_value(|h| h.is_some()) {
return;
}
// Adopt the inline early-mount's instance (started at HTML
// parse time) if it's there; otherwise (client-side
// navigation back to /) mount fresh through the helper the
// inline script left on window. Either order is safe.
let global = js_sys::global();
let _ = js_sys::Reflect::set(&global, &"__heroAdopted".into(), &true.into());
let early = js_sys::Reflect::get(&global, &"__heroEarly".into())
.ok()
.filter(|v| !v.is_undefined() && !v.is_null());
match early {
Some(v) => {
let _ = js_sys::Reflect::delete_property(&global, &"__heroEarly".into());
handle.set_value(Some(v));
}
None => {
if let Ok(mounter) = js_sys::Reflect::get(&global, &"__mountHero".into()) {
if let Some(f) = mounter.dyn_ref::<js_sys::Function>() {
if let Ok(promise) = f.call1(&global, &container) {
let promise: js_sys::Promise = promise.unchecked_into();
leptos::task::spawn_local(async move {
if let Ok(h) =
wasm_bindgen_futures::JsFuture::from(promise).await
{
handle.set_value(Some(h));
}
});
}
}
}
}
}
});
on_cleanup(move || {
handle.update_value(|opt| {
if let Some(h) = opt.take() {
if let Ok(stop) = js_sys::Reflect::get(&h, &"stop".into()) {
if let Some(f) = stop.dyn_ref::<js_sys::Function>() {
let _ = f.call0(&h);
}
}
}
});
});
}
// The inline script starts the piece at HTML parse time instead of
// waiting out the wasm bundle; it also leaves __mountHero for the
// hydrate side's client-side-navigation case. Module path is
// validated to plain segments by SiteConfig::validate, so it can't
// break out of the string here.
let early_script = module.as_ref().map(|m| {
format!(
"const url = '/site/{m}'; window.__mountHero = (el) => import(url).then((mod) => mod.mount(el)); if (!window.__heroAdopted && !window.__heroEarly) window.__mountHero(document.querySelector('.hero-piece')).then((h) => {{ if (!window.__heroAdopted) window.__heroEarly = h; else if (h && h.stop) h.stop(); }});"
)
});
// Who is here, and the way in or out, at the top of every page of
// a site that has sign-in at all.
let session = site.sign_in.then(|| {
let redirect = current_id.clone();
match user {
Some(user) => view! {
<nav class="session">
<span class="session-name">{user.display_name}</span>
<a href="/auth/logout" rel="external">{move || t(&lang().get(), "sign_out")}</a>
</nav>
}
.into_any(),
None => view! {
<nav class="session">
<a href=format!("/auth/login?redirect={redirect}") rel="external">{move || t(&lang().get(), "sign_in")}</a>
</nav>
}
.into_any(),
}
});
let has_slides = slideshow.is_some();
let mark = move || view! {
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
</a>
};
view! {
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
<Announcements current_id=current_id/>
{session}
{has_module
.then(|| {
view! {
<div class="hero-piece" aria-hidden="true" node_ref=piece_ref></div>
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
// With pictures, the wordmark is a bar of its own above
// them and the question comes under; without, the wordmark
// heads the copy as it always has.
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
<div class="hero-copy">
{(!has_slides).then(|| mark())}
<h1>{title}</h1>
2026-09-01 20:31:02 +02:00
<p inner_html=render_inline_markdown(&description)></p>
</div>
</header>
}
}
/// Pictures across the page (`slideshow`): the full width of the
/// screen, each picture as wide as its own shape at the strip's height,
/// the caption laid over its foot. Inert until the Swiper bundle
/// upgrades the elements; without it, a row that scrolls sideways.
#[component]
fn SlideStrip(show: crate::content::Slideshow) -> impl IntoView {
let height = format!("--slideshow-height: {}svh", show.max_height);
view! {
// In the page itself, so the strip works from the
// server's HTML alone; the head's copy above covers
// arriving here by a link within the site.
<script src="/swiper-element-bundle.min.js" defer=true></script>
<swiper-container
class="slideshow"
style=height
slides-per-view="auto"
space-between="6"
grab-cursor="true"
free-mode="true"
mousewheel-force-to-axis="true"
keyboard="true"
navigation="true"
>
<For
each={move || show.slides.clone().into_iter().enumerate().collect::<Vec<_>>()}
key=|(_, slide)| slide.image.clone()
children=|(n, slide): (usize, crate::content::Slide)| {
let caption = slide.caption.clone();
// The first screenful loads with the
// page; the rest when swiped to.
let loading = if n < 4 { "eager" } else { "lazy" };
view! {
<swiper-slide>
<figure class="slide">
<img
src=slide.src()
alt=slide.caption.clone()
width=slide.width.map(|w| w.to_string())
height=slide.height.map(|h| h.to_string())
loading=loading
decoding="async"
/>
{(!caption.trim().is_empty()).then(|| view! { <figcaption>{caption}</figcaption> })}
</figure>
</swiper-slide>
}
.into_any()
}
/>
</swiper-container>
}
}
/// An alternative's banner imagery: nothing, a plain image, or - for
/// several urls - a Swiper Element card deck. The `swiper-container`/
/// `swiper-slide` custom elements render inert server-side and upgrade
/// once the vendored bundle registers them (loaded per-page by
/// `QuestionView` only when some alternative actually needs it).
#[component]
fn AltImages(images: Vec<String>) -> impl IntoView {
match images.len() {
0 => ().into_any(),
1 => view! { <img class="alt-image" src=images[0].clone() alt="" /> }.into_any(),
_ => view! {
<swiper-container class="alt-image-deck" effect="cards" grab-cursor="true">
<For
each=move || images.clone()
key=|src| src.clone()
children=|src: String| view! {
<swiper-slide>
<img class="alt-image" src=src alt="" />
</swiper-slide>
}.into_any()
/>
</swiper-container>
}
.into_any(),
}
}
#[component]
fn AlternativeCard(
question_id: String,
alternative: Alternative,
parent_hash: Option<String>,
query_email: Option<String>,
/// The page's own place as `lat,lon,zoom`, when it has one: where
/// a `type: location` field opens if the field does not say
/// otherwise. A question about a street corner should not hand the
/// visitor the globe. Empty means the page is about nowhere.
#[prop(optional)]
place: String,
) -> impl IntoView {
// Only the browser mounts a map, so the server build never reads it.
#[cfg(not(feature = "hydrate"))]
let _ = &place;
// 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" inner_html=render_inline_markdown(&alternative.description)></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>{move || t(&lang().get(), "done")}</p> }.into_any(),
Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
}
}}
</section>
}
.into_any();
}
let has_action = alternative.action.is_some();
let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty());
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()));
// 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-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.
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
std::collections::HashMap::new();
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
std::collections::HashMap::new();
let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> =
std::collections::HashMap::new();
let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
// Gesture fields share field_map (their hidden input carries a
// JSON string), but submit re-parses them so the stored answer
// holds a real {points, key} object, not a double-encoded string.
let mut gesture_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
for feature in &alternative.features {
for req in &feature.requirements {
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple);
} else {
// Widget-owned fields hold JSON objects, not strings.
if req.kind == "gesture" || req.kind == "voice" || req.kind == "location" {
gesture_fields.insert(req.name.clone());
}
// A voice field's preset is the key it records at,
// not a value to submit.
let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() };
field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(preset));
}
}
}
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.
let field_prefix = format!(
"{question_id}-{}",
alternative.name.to_lowercase().replace(' ', "-")
);
let submit = ServerAction::<SubmitAnswer>::new();
let navigate = use_navigate();
let question_id_for_submit = question_id.clone();
let alt_name_for_submit = alternative.name.clone();
let parent_hash_for_submit = parent_hash.clone();
let field_map_for_submit = field_map.clone();
let file_refs_for_submit = file_refs.clone();
let select_field_map_for_submit = select_field_map.clone();
let select_multi_for_submit = select_multi.clone();
let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let pending = pending_transitions.get_untracked();
if !pending.is_empty() {
let items = pending
.into_iter()
.map(|((feature_name, item_id), to)| TransitionItem { feature_name, item_id, to })
.collect();
transition_batch.dispatch(TransitionAnswers {
question_id: question_id_for_transition.clone(),
alternative: alt_name_for_transition.clone(),
items,
});
}
if !has_requirements {
return;
}
let field_map_for_submit = field_map_for_submit.clone();
let file_refs_for_submit = file_refs_for_submit.clone();
let select_field_map_for_submit = select_field_map_for_submit.clone();
let select_multi_for_submit = select_multi_for_submit.clone();
let gesture_fields_for_submit = gesture_fields_for_submit.clone();
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
let value = if gesture_fields_for_submit.contains(name) {
// The widget's hidden input holds {points, key} as
// JSON; store the object itself (empty/never-drawn
// becomes null, not "").
serde_json::from_str(&sig.get()).unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::String(sig.get())
};
map.insert(name.clone(), value);
}
for (name, sig) in select_field_map_for_submit.iter() {
let ids = sig.get();
let multiple = select_multi_for_submit.get(name).copied().unwrap_or(false);
let value = if multiple {
serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect())
} else {
serde_json::Value::String(ids.into_iter().next().unwrap_or_default())
};
map.insert(name.clone(), value);
}
// Only used under hydrate (file uploads are a browser-only
// concern); referenced unconditionally so an `ssr` build
// doesn't warn about it going unused.
let _ = &file_refs_for_submit;
#[cfg(feature = "hydrate")]
for (name, file_ref) in file_refs_for_submit.iter() {
let Some(input) = file_ref.get_untracked() else {
continue;
};
let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue };
2026-09-30 21:08:28 +02:00
// Too big is known before a byte is sent.
if file.size() > MAX_UPLOAD_BYTES as f64 {
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
sending.set(false);
return;
}
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
}
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
2026-09-30 21:08:28 +02:00
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
sending.set(false);
return;
}
}
}
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,
});
sending.set(false);
});
};
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(|| t(&lang().get(), "send").to_string());
view! {
<section class="alt-card">
<AltImages images=alternative.images.clone() />
<h2>{alternative.name.clone()}</h2>
<p class="alt-description" inner_html=render_inline_markdown(&alternative.description)></p>
<div class="features">
<For
each={
let features = alternative.features.clone();
move || features.clone()
}
key=|f| f.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alternative.name.clone();
move |feature| {
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alt_name.clone();
let resource = feature.resource.clone();
let feature_name = feature.name.clone();
let color = feature.color.clone();
let icon = feature.icon.clone();
view! {
<div class="feature" style:--feature-accent=color>
{icon.filter(|i| is_safe_icon_name(i)).map(|icon| {
// Masked span, not <img>: an image can't take
// currentColor, a mask painted by background-color can.
let u = format!("https://api.iconify.design/{icon}.svg");
view! {
<span
class="feature-icon"
style=format!("mask-image:url({u});-webkit-mask-image:url({u})")
></span>
}
})}
<Show when={
let name = feature.name.clone();
move || !name.is_empty()
}>
<h3>{feature.name.clone()}</h3>
</Show>
<Show when={
let description = feature.description.clone();
move || !description.is_empty()
}>
<p inner_html=render_inline_markdown(&feature.description)></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()
2026-09-30 06:47:36 +02:00
link=spec.link.clone()
bucket=spec.bucket().map(str::to_string)
empty=spec.empty.clone()
pending_transitions=pending_transitions
transition_batch=transition_batch
/>
}
})}
<For
each={
let reqs = feature.requirements.clone();
move || reqs.clone()
}
key=|r| r.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alt_name.clone();
let feature_name = feature_name.clone();
let _place = place.clone();
move |req| {
let field_id = format!("{}-{}", field_prefix, req.name);
let label_for = field_id.clone();
let label_text = view! {
<span class="field-label">
{req.display_label()}
{(!req.optional).then(|| view! { <span class="required">"*"</span> })}
</span>
};
let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
// A bound field watches a sibling and loads its own value from
// the bind's resource whenever the sibling changes. An empty
// sibling never fetches - and never clears an edit.
if let Some(bind) = req
.bind
.clone()
.filter(|_| !matches!(req.kind.as_str(), "select" | "file"))
{
let watched_select = select_field_map.get(&bind.field).copied();
let watched_text = field_map.get(&bind.field).copied();
let param_name = bind.param_name().to_string();
let q = question_id.clone();
let a = alt_name.clone();
let f = feature_name.clone();
let rname = req.name.clone();
let bound = Resource::new(
move || {
let value = watched_select
.map(|s| s.get().first().cloned().unwrap_or_default())
.or_else(|| watched_text.map(|s| s.get()))
.unwrap_or_default();
(q.clone(), a.clone(), f.clone(), rname.clone(), param_name.clone(), value)
},
|(q, a, f, r, p, value)| async move {
if value.is_empty() {
return Ok(serde_json::Value::Null);
}
let params = std::collections::HashMap::from([(p, value)]);
get_requirement_binding(q, a, f, r, params).await
},
);
Effect::new(move |_| {
if let Some(Ok(value)) = bound.get() {
let text = match value {
serde_json::Value::Null => return,
serde_json::Value::String(s) => s,
// apply_jq wraps every yield in an array - a
// single string yield unwraps to itself.
serde_json::Value::Array(items) if items.len() == 1 => {
match &items[0] {
serde_json::Value::String(s) => s.clone(),
other => serde_json::to_string_pretty(other).unwrap_or_default(),
}
}
other => serde_json::to_string_pretty(&other).unwrap_or_default(),
};
sig.set(text);
}
});
}
let is_bound = req.bind.is_some();
if req.kind == "select" {
let select_sig = select_field_map
.get(&req.name)
.copied()
.unwrap_or_else(|| RwSignal::new(Vec::new()));
return view! {
<div class="field">
{label_text}
<SelectField
question_id=question_id.clone()
alternative=alt_name.clone()
feature_name=feature_name.clone()
requirement_name=req.name.clone()
multiple=req.multiple
id_field=req.id_field.clone()
sig=select_sig
/>
</div>
}
.into_any();
}
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! {
<label class="field" for=label_for>
{label_text}
{current}
<input
id=field_id
type="file"
accept=req.accept.clone().unwrap_or_default()
multiple=req.multiple
required=!req.optional
node_ref=file_ref
/>
</label>
}
.into_any();
}
if req.kind == "gesture" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
// A div, not a label: a label would forward
// every click to the hidden input, and this
// field is drawn on, not clicked into.
#[cfg(feature = "hydrate")]
{
let relay = req.relay.clone().unwrap_or_default();
// Unlike prosekit's fire-and-forget mount,
// the widget may own a live WebSocket -
// yes.js's StoredValue + on_cleanup pattern
// closes it on SPA navigation.
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(gesture::mount_gesture(
&container, &hidden, &relay,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="gesture-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "location" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
// `value` opens the map somewhere useful
// ("lat,lon,zoom"); without it the map
// opens on the world.
let initial = req
.value
.clone()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| _place.clone());
let widget: StoredValue<Option<map::MapWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(map::mount_map(
&container, &hidden, &initial,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="map-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "voice" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
let relay = req.relay.clone().unwrap_or_default();
let key = req.value.clone().unwrap_or_default();
let widget: StoredValue<Option<voice::VoiceWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(voice::mount_voice(
&container, &hidden, &relay, &key,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="voice-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
prosekit::mount_editor(&container, &hidden, &sig.get_untracked());
});
}
return view! {
<label class="field" for=label_for>
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="prosekit-wrap" node_ref=container_ref></div>
</label>
}
.into_any();
}
2026-09-25 21:35:25 +02:00
// `record` carries the case this one belongs to.
// Nobody types a chain hash, so it rides in on the
// link and is carried, not asked for — the label
// row would only ask a person to confirm a string
// that means nothing to them.
if req.kind == "hidden" || req.kind == "record" {
// A carrier value only — no label row, or a
// preset key reads as a second visible field.
return view! {
<input
id=field_id
type="hidden"
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
}
.into_any();
}
view! {
<label class="field" for=label_for>
{label_text}
<Show
when={
let kind = req.kind.clone();
move || kind == "textarea"
}
fallback={
let kind = req.kind.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let field_id = field_id.clone();
let required = !req.optional;
move || {
view! {
<input
id=field_id.clone()
type=kind.clone()
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
}
}
}
>
{
let field_id = field_id.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let required = !req.optional;
view! {
<textarea
id=field_id.clone()
class:bound=is_bound
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
></textarea>
}
}
</Show>
</label>
}
.into_any()
}
}
/>
</div>
}
.into_any()
}
}
/>
</div>
{has_action
.then(|| {
view! {
<div class="alt-cta">
<div class="alt-encouragements">
<For
each=move || alternative.encouragements.clone()
key=|e| e.clone()
children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/>
</div>
{if alternative.disabled {
// Advertised, not yet takeable: the label
// stays (it says what will be possible),
// the button doesn't act.
view! {
<button type="button" class="alt-submit soon" disabled=true title="Not yet">
{button_label.clone()}
</button>
}
.into_any()
} else if is_gateway {
2026-08-12 23:09:55 +02:00
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())
|| sending.get()
2026-08-12 23:09:55 +02:00
|| submit.pending().get()
|| transition_batch.pending().get()
}
>
{button_label.clone()}
</button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
2026-08-12 23:09:55 +02:00
</form>
}
.into_any()
}}
</div>
}
})}
</section>
}
.into_any()
}
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
/// Fully generic: it has no idea what "/review" or "applicants" are - it
/// just fetches `question_id`/`feature_name`'s data and renders it
/// according to its own shape (see `ResourceValue`), with one button
/// per `transitions` entry when the data looks like a list of answers.
#[component]
fn ResourceFeature(
question_id: String,
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
2026-09-30 06:47:36 +02:00
link: Option<RowLink>,
bucket: Option<String>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
let data = Resource::new(
{
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
move || (question_id.clone(), alternative.clone(), feature_name.clone())
},
// Empty params for now - forwarding sibling requirement values
// as query params (for a resource parameterized by other form
// fields) is deferred, see the resource-backed multi-select
// requirement UI follow-up.
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
);
// The shared batch button lives on `AlternativeCard`, above every
// feature on the page - any successful confirm refetches this
// feature's own list, whether or not the batch actually touched
// this particular resource (harmless extra refetch either way).
Effect::new(move |_| {
if let Some(Ok(())) = transition_batch.value().get() {
data.refetch();
}
});
view! {
<div class="resource">
<Suspense fallback=|| view! { <p class="resource-loading" aria-busy="true"></p> }>
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
2026-09-30 06:47:36 +02:00
let link = link.clone();
let bucket = bucket.clone();
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get()
.map(|res| match res {
Ok(value) => {
view! {
<ResourceValue
feature_name=feature_name
transitions=transitions
2026-09-30 06:47:36 +02:00
link=link
bucket=bucket
empty=empty
value=value
pending_transitions=pending_transitions
transition_batch=transition_batch
/>
}
.into_any()
}
Err(e) => {
view! { <p class="resource-error">{e.to_string()}</p> }.into_any()
}
})
}}
</Suspense>
</div>
}
}
/// Renders whatever `get_resource` returned, purely from its shape: an
/// array that parses as `answers::Answer` renders as a list (with
/// transition buttons, if any were declared); anything else renders as
/// a plain, read-only dump. No `kind`/render-mode tag anywhere - the
/// data decides.
#[component]
fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
2026-09-30 06:47:36 +02:00
link: Option<RowLink>,
bucket: Option<String>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as [].
if value.is_null() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let serde_json::Value::Array(items) = &value {
// Before the Answer parse below: an empty list deserializes as
// an (empty) Vec<Answer> too, and rendered as a blank
// answer-list instead of saying anything.
if items.is_empty() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
// Cards are shown in the order they were put up, the way a
// team or a gallery is read; a list of cases, newest first.
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
open(&a.state).cmp(&open(&b.state)).then(when)
});
return view! {
<div class="answer-list" class:answer-cards=cards>
<For
each=move || answers.clone()
key=|a| a.id.clone()
children={
let feature_name = feature_name.clone();
let transitions = transitions.clone();
2026-09-30 06:47:36 +02:00
let link = link.clone();
let bucket = bucket.clone();
move |answer: Answer| {
view! {
<AnswerRow
feature_name=feature_name.clone()
transitions=transitions.clone()
2026-09-30 06:47:36 +02:00
link=link.clone()
bucket=bucket.clone()
answer=answer
pending_transitions=pending_transitions
transition_batch=transition_batch
/>
}
.into_any()
}
}
/>
</div>
}
.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"]);
// A playable recording (an https URL to audio) renders as a
// player, not a link - the relay's /blob/<digest>.wav.
let audio = text_field(&obj, &["audio"]).filter(|a| a.starts_with("https://"));
let known = ["name", "title", "description", "url", "html_url", "audio"];
// A place renders as a still from this origin, the same as one
// stored on a record: the first location field wins, since a card
// shows one map.
let place = obj
.iter()
.filter(|(k, _)| !known.contains(&k.as_str()))
.find_map(|(_, v)| crate::maps::read(v))
.map(|(lat, lon, zoom)| (crate::maps::still_url(lat, lon, zoom, 480, 240), format!("{lat}, {lon}")));
let extra: Vec<(String, String)> = obj
.iter()
.filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null() && crate::maps::read(v).is_none())
.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" inner_html=render_inline_markdown(&d)></p> })}
{audio.map(|src| view! { <audio class="item-card-audio" controls preload="none" src=src></audio> })}
{place
.map(|(src, coords)| {
view! {
<img class="map-still" src=src alt=format!("Map of {coords}") loading="lazy" />
<span class="map-coords">{coords}</span>
}
})}
{(!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" aria-busy="true"></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>
}
}
/// The file's own name, if a field's value is an upload's key: a
/// uuid, a dash, and the name it was sent with (`upload.rs`).
pub fn uploaded_filename(value: &str) -> Option<&str> {
let (id, rest) = value.split_at_checked(36)?;
let shaped = id.char_indices().all(|(i, c)| if matches!(i, 8 | 13 | 18 | 23) { c == '-' } else { c.is_ascii_hexdigit() });
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
2026-09-30 21:08:28 +02:00
/// The largest file one upload may carry. Here, not in `upload.rs`,
/// so the browser checks the same number before it sends anything.
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
/// Why an upload did not arrive, in words a person can act on, from
/// the status `POST /upload` answered with (see `upload.rs`): too big,
/// a site that takes no files, a page they may not send from, or
/// anything else.
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
fn upload_refusal(status: Option<u16>) -> &'static str {
match status {
Some(413) => "upload_too_large",
Some(503) => "upload_unavailable",
Some(403) => "upload_forbidden",
_ => "upload_failed",
}
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
}
/// A record that carries a picture, in a list nobody works from, is
/// shown rather than read: the picture, then what it says, first field
/// as its heading. A team, a gallery of what has been built.
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
let bucket = bucket?;
answer.responses.as_object()?.iter().find_map(|(key, value)| {
let name = uploaded_filename(value.as_str()?)?;
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
})
}
#[component]
fn AnswerRow(
feature_name: String,
transitions: Vec<Transition>,
2026-09-30 06:47:36 +02:00
link: Option<RowLink>,
bucket: Option<String>,
answer: Answer,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
// Only the transitions legal from this row's current state - two
// declared transitions may share a target (open -> declined and
// in_dialogue -> declined), each rendered only on rows actually in
// its `from` state.
let available: Vec<Transition> = transitions
.iter()
.filter(|t| t.from == answer.state)
.cloned()
.collect();
// "Decided" (dimmed) means this row has no legal move here. A
// read-only listing (no transitions declared at all) keeps the old
// rule - dim anything past `open` - since "no legal move" would
// dim every row indiscriminately.
let decided = if transitions.is_empty() {
answer.state != crate::answers::OPEN_STATE
} else {
available.is_empty()
};
if transitions.is_empty() {
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
return AnswerCard(answer, picture, link).into_any();
}
}
let responses = answer
.responses
.as_object()
.cloned()
.unwrap_or_default();
let submitted_ms = answer.submitted_ms;
let notes = answer.notes.clone();
let state = answer.state.clone();
let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone());
2026-09-30 06:47:36 +02:00
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
// Where a stored file on this record is fetched from: the record
// names it, never the key (see `upload::attachment`).
let attachment_base = bucket.map(|b| format!("/attachment/{b}/{}", answer.id));
view! {
<article class="answer-row" class:decided=move || decided>
<div class="answer-fields">
<For
each={
let responses = responses.clone();
move || responses.clone().into_iter().collect::<Vec<_>>()
}
key=|(k, _)| k.clone()
children=move |(key, value)| {
// A stored location is a place, so it renders as
// one: a still from this origin, with the
// coordinates under it for anyone who wants to
// put them into something else.
if let Some((lat, lon, zoom)) = crate::maps::read(&value) {
return view! {
<div class="answer-field answer-field-map">
<span class="answer-field-key">{key}</span>
<span class="answer-field-value">
<img
class="map-still"
src=crate::maps::still_url(lat, lon, zoom, 480, 240)
alt=format!("Map of {lat}, {lon}")
loading="lazy"
/>
<span class="map-coords">{format!("{lat}, {lon}")}</span>
</span>
</div>
}
.into_any();
}
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
// A stored file shows as its name, and opens.
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
let href = format!("{base}/{key}");
if is_picture(filename) {
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value" href=href.clone() rel="external">
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
</a>
</div>
}
.into_any();
}
return view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<a class="answer-field-value answer-file" href=href rel="external">{filename.to_string()}</a>
</div>
}
.into_any();
}
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>
2026-09-30 06:47:36 +02:00
// The page about this record, when the list offers one:
// where a reader answers a notice.
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
// What the person has written back since, oldest first: the
// conversation beside the case, which a desk could not see
// before. A note moves nothing, so there is no button here.
{(!notes.is_empty())
.then(|| {
view! {
<ol class="answer-notes">
<For
each=move || notes.clone()
key=|note| note.id.clone()
children=move |note| {
view! {
<li class="answer-note">
<span class="answer-note-who">{note.from}</span>
<span class="answer-note-time">{format_ms(note.received_ms)}</span>
<p class="answer-note-text">{note.text}</p>
</li>
}
}
/>
</ol>
}
})}
<Show when={
let available = available.clone();
move || !available.is_empty()
}>
{
let key = key.clone();
let available = available.clone();
view! {
<div class="answer-actions">
<div class="select-options">
<For
each={
let available = available.clone();
move || available.clone()
}
key=|t| t.to.clone()
children=move |t: Transition| {
let key_for_class = key.clone();
let key_for_click = key.clone();
let to_for_class = t.to.clone();
let to_for_click = t.to.clone();
view! {
<button
type="button"
class="select-option"
class:selected=move || {
pending_transitions.get().get(&key_for_class) == Some(&to_for_class)
}
disabled=move || transition_batch.pending().get()
on:click=move |_| {
let key = key_for_click.clone();
let to = to_for_click.clone();
pending_transitions.update(|m| {
if m.get(&key) == Some(&to) {
m.remove(&key);
} else {
m.insert(key, to);
}
});
}
>
{t.label.clone()}
</button>
}
.into_any()
}
/>
</div>
</div>
}
}
</Show>
</article>
}
.into_any()
}
/// A record shown as a card: its picture, its first field as the
/// heading and the others under it, and nothing a reader has no use
/// for - no field names, no state, no time.
#[allow(non_snake_case)]
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
let texts: Vec<String> = answer
.responses
.as_object()
.into_iter()
.flatten()
.filter_map(|(_, v)| v.as_str())
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
.map(str::to_string)
.collect();
let heading = texts.first().cloned().unwrap_or_default();
let rest: Vec<String> = texts.into_iter().skip(1).collect();
view! {
<article class="answer-row answer-card">
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
<div class="answer-card-text">
<h4 class="answer-card-heading">{heading}</h4>
{rest.into_iter().map(|t| view! { <p>{t}</p> }).collect_view()}
</div>
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
</article>
}
}
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
/// with-hyphens by convention - this is just a character allow-list
/// before the value goes into a URL, not a lookup against Iconify's
/// real prefix/name list.
fn is_safe_icon_name(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':')
}
fn format_ms(ms: i64) -> String {
#[cfg(feature = "ssr")]
{
use chrono::TimeZone;
chrono::Utc
.timestamp_millis_opt(ms)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| ms.to_string())
}
// Rows re-rendered client-side (a post-confirm refetch) format
// here, not on the server - raw milliseconds would show otherwise.
#[cfg(all(not(feature = "ssr"), feature = "hydrate"))]
{
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms as f64));
format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
date.get_utc_full_year(),
date.get_utc_month() + 1,
date.get_utc_date(),
date.get_utc_hours(),
date.get_utc_minutes(),
)
}
#[cfg(all(not(feature = "ssr"), not(feature = "hydrate")))]
{
ms.to_string()
}
}
#[component]
fn NotFound() -> impl IntoView {
view! {
<main class="not-found">
<h1>{move || t(&lang().get(), "nothing_here")}</h1>
<a href="/">{move || t(&lang().get(), "back_to_start")}</a>
</main>
}
}
/// What a URL resolves to: the question (dynamic pages arrive with
/// their segment value already substituted, see
/// `content::resolve_question`) plus whether a `requires_chain` gate
/// blocked it - `(target id, target name)` so the gate can point the
/// visitor at where the required answer comes from.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Page {
pub question: Question,
pub chain_gate: Option<(String, String)>,
}
#[server(endpoint = "get_question")]
pub async fn get_question(
path: String,
chain: Option<String>,
) -> Result<Option<Page>, ServerFnError> {
2026-09-30 06:47:36 +02:00
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::{fill_from_user, path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::<AppState>();
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
let Some(mut question) = resolve_question(questions, &path) else {
return Ok(None);
};
2026-09-30 06:47:36 +02:00
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()))?;
fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None;
if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required
// question - an unindexed or absent hash reads as unverified,
// and the gate stays shut. Dynamic targets match any concrete
// answer of theirs.
let verified = match &chain {
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
.await
.is_some_and(|node| {
node.question_id == *target
|| path_matches(target, &node.question_id).is_some()
}),
None => false,
};
if !verified {
let name = questions
.get(target)
.map(|q| q.name.clone())
.unwrap_or_default();
chain_gate = Some((target.clone(), name));
}
}
Ok(Some(Page {
question,
chain_gate,
}))
}
/// The content repo's branding (`site.yaml`) - title, wordmark, hero
/// kind. Defaults (= the historical uhhm look) when the repo declares
/// nothing.
#[server(endpoint = "get_site")]
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let mut site = state.site.load().at(crate::server::storyline::current().await).clone();
// Not content: whether this instance can serve tiles at all is its
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
site.maps = crate::maps::enabled();
site.sign_in = state.oidc.enabled();
Ok(site)
}
/// Every question the visitor may be offered from `current`, as (id,
/// name) - the question nav's data. Front-door pages the visitor
/// qualifies for are always in (an owner session sees the gated desks
/// too). A `followup` page or an ended event is in only when the
/// visitor has earned it: their verified `?chain=` answered the
/// alternative that leads there, or it is reachable from an earned
/// page over links that record nothing (see `content::earned_followups`).
/// Nothing is offered that sits behind an answer they have not given;
/// an unverifiable hash unlocks nothing.
#[server(endpoint = "list_qualifying_questions")]
pub async fn list_qualifying_questions(
current: String,
chain: Option<String>,
) -> Result<Vec<(String, String)>, ServerFnError> {
2026-08-12 23:09:55 +02:00
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::{earned_followups, unlocked_by, Question};
2026-08-12 23:09:55 +02:00
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 now_ms = chrono::Utc::now().timestamp_millis();
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
// What the chain verifiably answered - the same lookup a
// `requires_chain` gate does, so a forged or foreign hash resolves
// to nothing - and therefore where it sent the visitor.
let seeds: Vec<String> = match &chain {
Some(hash) => match crate::chain::lookup_node(&state.jetstream, hash).await {
Some(node) => unlocked_by(&questions, &node.question_id, node.alternative.as_deref()),
None => Vec::new(),
},
None => Vec::new(),
};
// An ended event page is a followup from then on.
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
let earned = earned_followups(&questions, &current, &seeds, &is_outcome);
let access = state.access.load();
let principal = crate::access::Principal::from_user(user.as_ref());
let may_open = |q: &Question| {
access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() })
};
let mut out: Vec<(String, String)> = questions
2026-08-12 23:09:55 +02:00
.values()
.filter(|q| may_open(q))
.filter(|q| !is_outcome(q) || earned.contains(&q.id))
// A live event is announced in the header instead.
.filter(|q| !q.event_live_at(now_ms))
// A dynamic page has no URL of its own to link to.
.filter(|q| !q.is_dynamic())
2026-08-12 23:09:55 +02:00
.map(|q| (q.id.clone(), q.name.clone()))
.collect();
out.sort();
Ok(out)
}
/// Every announced page whose window is still open and that the
/// visitor qualifies for, soonest first.
#[server(endpoint = "list_announcements")]
pub async fn list_announcements() -> Result<Vec<Announcement>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
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 now_ms = chrono::Utc::now().timestamp_millis();
let access = state.access.load();
let principal = crate::access::Principal::from_user(user.as_ref());
let mut live: Vec<(i64, Announcement)> = state
.questions
.load()
.values()
.filter(|q| {
access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() })
&& q.event_live_at(now_ms)
})
.filter_map(|q| {
let event = q.event.as_ref()?;
let starts = chrono::DateTime::parse_from_rfc3339(&event.starts).ok()?;
let starts_ms = starts.timestamp_millis();
let relative = relative_until(starts_ms - now_ms);
Some((
starts_ms,
Announcement {
id: q.id.clone(),
name: q.name.clone(),
when: starts.format("%a %-d %b, %H:%M").to_string(),
relative,
place: event.place.clone(),
},
))
})
.collect();
live.sort_by_key(|(starts, _)| *starts);
Ok(live.into_iter().map(|(_, a)| a).collect())
}
/// "in 3 days" / "in 4 h" / "in 20 min" / "now" (already started).
#[cfg(feature = "ssr")]
fn relative_until(delta_ms: i64) -> String {
const MIN: i64 = 60_000;
const HOUR: i64 = 60 * MIN;
const DAY: i64 = 24 * HOUR;
if delta_ms <= 0 {
"now".to_string()
} else if delta_ms >= 2 * DAY {
format!("in {} days", delta_ms / DAY)
} else if delta_ms >= DAY {
"tomorrow".to_string()
} else if delta_ms >= HOUR {
format!("in {} h", delta_ms / HOUR)
} else {
format!("in {} min", (delta_ms / MIN).max(1))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult {
pub next: Option<String>,
pub chain_hash: String,
}
/// A change to a page, suggested by the person it names as responsible.
/// Checked here, not trusted from the page: the session's address must
/// be the page's responsible contact. It becomes a case in
/// `portal_edit_suggestions` (open, discussed, done or declined, see
/// the built-in aggregates), is announced like any answer and on
/// `portal.edits.suggested`, and is mailed to the site's inbox.
#[server(endpoint = "suggest_page_edit")]
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
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 text = text.trim().to_string();
if text.is_empty() || text.len() > 8000 {
return Err(ServerFnError::new("a suggestion is between one and 8000 characters"));
}
let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
2026-09-30 08:17:27 +02:00
if !is_responsible(user.as_ref(), &responsible) {
return Err(ServerFnError::new("only whoever is responsible for this page may suggest changes to it here"));
}
let user = user.expect("is_responsible needs a user");
let email = user.email.clone().unwrap_or_default();
let now_ms = chrono::Utc::now().timestamp_millis();
// The fields a desk shows and a mail may name: who, what, and
// which page. `email` is where word of what became of it goes.
let responses = serde_json::json!({
"name": user.display_name, "email": email, "by": user.username,
"page": question_id, "question": question.name, "text": text,
});
let id = hash_node(&question_id, &[], &responses, now_ms);
crate::answers::store_answer(
&state.jetstream,
&state.aggregates.load(),
crate::content::EDIT_SUGGESTIONS_BUCKET,
id.clone(),
&question_id,
SUGGESTION_ALTERNATIVE,
&responses,
now_ms,
0,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let event = AnswerSubmitted {
chain_hash: id,
parent_hashes: Vec::new(),
question_id: question_id.clone(),
alternative: SUGGESTION_ALTERNATIVE.to_string(),
responses: responses.clone(),
timestamp_ms: now_ms,
storyline: 0,
};
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
}
if let Ok(bytes) = serde_json::to_vec(&responses) {
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
}
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
Ok(())
}
/// What a suggestion is recorded under, where an answer has the name
/// of the alternative that was chosen.
#[cfg(feature = "ssr")]
pub const SUGGESTION_ALTERNATIVE: &str = "Suggested change";
/// The signed-in person's own suggestions for a page, newest first,
/// with where each one stands. Theirs only: matched on the session's
/// address, which is what a suggestion was filed under.
#[server(endpoint = "my_suggestions")]
pub async fn my_suggestions(question_id: String) -> Result<Vec<MySuggestion>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
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 Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
return Ok(Vec::new());
};
// Made on first write: nobody has suggested anything yet.
let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else {
return Ok(Vec::new());
};
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => Vec::new(),
}
};
let mut mine = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else { continue };
let Ok(answer) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email);
if theirs && answer.question_id == question_id {
mine.push(MySuggestion {
id: answer.id,
text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
state: answer.state,
submitted_ms: answer.submitted_ms,
});
}
}
mine.sort_by_key(|s| std::cmp::Reverse(s.submitted_ms));
Ok(mine)
}
/// Who a principal is, for a record: the username, or the service.
#[cfg(feature = "ssr")]
fn principal_name(principal: &crate::access::Principal) -> String {
match principal {
crate::access::Principal::User { sub, .. } => sub.clone(),
crate::access::Principal::Submitter { email, .. } => email.clone(),
crate::access::Principal::Service { name } => name.clone(),
}
}
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
alternative: String,
parent_hash: Option<String>,
responses_json: String,
) -> Result<SubmitResult, ServerFnError> {
use crate::answers::store_answer;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
// resolve_question, not a plain map get: a dynamic page's concrete
// path (what the client holds as its question id) resolves to the
// pattern page it came from.
let storyline = crate::server::storyline::current().await;
let question = crate::content::resolve_question(state.questions.load().at(storyline), &question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let principal = crate::access::Principal::from_user(user.as_ref());
let submit = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&principal,
crate::access::Action::Submit,
&crate::access::Resource::Page { id: question_id.clone() },
serde_json::json!({ "alternative": alternative }),
);
if !submit.allow {
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"))?;
if alt.disabled {
return Err(ServerFnError::new("this alternative isn't open yet"));
}
let mut record_as = alt.record_as.clone();
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
2026-09-25 21:35:25 +02:00
// A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field
// left empty points at nothing and is nothing to check.
for requirement in alt.features.iter().flat_map(|f| &f.requirements) {
let (Some(of), true) = (&requirement.of, requirement.kind == "record") else {
continue;
};
let given = responses.get(&requirement.name).and_then(|v| v.as_str()).map(str::trim).unwrap_or_default();
if given.is_empty() {
if requirement.optional {
continue;
}
return Err(ServerFnError::new(format!("this answer is missing the {} it is about", requirement.name)));
}
let found = match state.jetstream.get_key_value(of).await {
Ok(store) => store.get(given).await.ok().flatten().is_some(),
Err(_) => false,
};
if !found {
return Err(ServerFnError::new(format!("the {} this is about is not on file", requirement.name)));
}
}
// An invite: the policy says whether this group may extend one,
// Kanidm does the onboarding, and what came back (the account, the
// one-time link) is recorded with the answer for the inviter's desk
// and announced for whatever sends the mail.
if let Some(invite) = &alt.invite {
let may = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&principal,
crate::access::Action::Invite,
&crate::access::Resource::Group { name: invite.group.clone() },
serde_json::json!({}),
);
if !may.allow {
return Err(ServerFnError::new("not authorized to invite into this group"));
}
let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?;
let Some((name, who)) = crate::people::who_for(&responses) else {
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
};
let invited = people
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
.await
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
if let Some(map) = responses.as_object_mut() {
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
}
// Through Kanidm the invite is its own case, with a link that
// runs out; a membership is the case already.
if people.is_kanidm() {
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
}
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
}
crate::mail::on_invite(&state, &invited).await;
}
// `/shape/{curve.key}`: the next page may be chosen by the answer.
let next = match alt.action.as_deref() {
Some(action) => Some(
crate::content::template_action(action, &responses)
.ok_or_else(|| ServerFnError::new("this answer is missing what the next page needs"))?,
),
None => None,
};
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,
storyline: storyline as u32,
},
)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
// Index the node so requires_chain pages can verify lineage.
// Best-effort: the NATS event above is the durable record.
if let Err(e) =
crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, &alternative, timestamp_ms).await
{
tracing::error!("failed to index chain node: {e}");
}
// Best-effort: a bucket-write hiccup shouldn't fail a submission the
// NATS event has already recorded.
if let Some(bucket) = &record_as {
if let Err(e) = store_answer(
&state.jetstream,
&state.aggregates.load(),
bucket,
chain_hash.clone(),
&question_id,
&alternative,
&responses,
timestamp_ms,
storyline as u32,
)
.await
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses, storyline as u32).await;
2026-09-25 21:35:25 +02:00
crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
}
}
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,
question_id: &str,
alternative: &str,
) -> 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()?;
// Which page and which choice this file is being attached to. The
// server refuses an upload that does not name a page it may
// submit, so these travel with every file.
form.append_with_str("question", question_id)?;
form.append_with_str("alternative", alternative)?;
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"))
}
2026-09-29 07:05:39 +02:00
#[cfg(test)]
mod down_the_tree_tests {
use super::down_the_tree;
fn pages(ids: &[&str]) -> Vec<(String, String)> {
ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect()
}
#[test]
fn the_way_down_is_the_nearest_page_below_and_never_a_dead_end() {
let p = pages(&["/", "/melde-fra", "/review"]);
// One level down, to a page that exists.
assert_eq!(down_the_tree("/melde-fra", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/review/abc123", &p).unwrap().0, "/review");
// Segments that are no page of their own are skipped.
assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/");
// A trailing slash or a query does not count as a level.
assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review");
// Named: the start, or the page's question in quotation marks.
assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten");
assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»");
assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}");
// The front door has nowhere further down.
assert!(down_the_tree("/", &p).is_none());
// A visitor who may not open the front door still gets "/".
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
}
}
#[cfg(test)]
mod responsible_tests {
use super::is_responsible;
use crate::auth::User;
2026-09-30 08:17:27 +02:00
use crate::content::Responsible;
2026-09-30 08:17:27 +02:00
fn user(email: Option<&str>, groups: &[&str]) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) }
2026-09-30 08:17:27 +02:00
}
fn named(contact: &str, group: Option<&str>) -> Responsible {
Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) }
}
#[test]
fn an_uploads_key_shows_as_the_name_it_was_sent_with() {
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), Some("rapport 2026.pdf"));
assert_eq!(super::uploaded_filename("rapport.pdf"), None);
assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), None);
assert_eq!(super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), None);
}
#[test]
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
2026-09-30 08:17:27 +02:00
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(None, &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None)));
}
#[test]
fn a_page_a_group_answers_for_is_every_members() {
let board = named("post@vel.no", Some("styret"));
assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board));
// The inbox itself, signed in, still counts.
assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board));
assert!(!is_responsible(None, &board));
}
}
#[cfg(test)]
mod picture_tests {
use super::*;
2026-09-30 21:08:28 +02:00
#[test]
fn an_upload_refusal_says_which_it_was() {
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
assert_eq!(upload_refusal(Some(502)), "upload_failed");
assert_eq!(upload_refusal(None), "upload_failed");
for lang in ["en", "no"] {
for status in [Some(413), Some(503), Some(403), None] {
let key = upload_refusal(status);
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
}
}
}
#[test]
fn a_record_with_a_picture_is_shown_by_it() {
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
let answer: Answer = serde_json::from_value(serde_json::json!({
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
}))
.unwrap();
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
// Without a bucket there is nowhere to fetch it from.
assert_eq!(picture_of(&answer, None), None);
}
}