/upload asked nothing. It took a file, capped it at 20 MB, and wrote it to the bucket for whoever sent it - no session, no page, no policy. It was the one server entry point that did not ask the question every other one asks, and on a site whose forms are public that makes the route public too: a free disk for anyone who finds the host. Harmless while no instance had storage configured, which is why it sat there. kasse now has a garage wired to klingenberg's portal, so it is not harmless any more. An upload now carries the question and the alternative the file is being attached to. The page has to exist, the alternative has to be open and to actually declare a `type: file` field, and the policy has to allow Submit on that page for whoever is asking - checked after the bytes are read and before any of them are written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2581 lines
124 KiB
Rust
2581 lines
124 KiB
Rust
use leptos::prelude::*;
|
|
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};
|
|
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, 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()/>
|
|
<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()?))
|
|
})
|
|
});
|
|
// 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/> })}
|
|
{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>
|
|
// 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.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()
|
|
/>
|
|
// 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()
|
|
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()
|
|
/>
|
|
}
|
|
.into_any()
|
|
}
|
|
}
|
|
/>
|
|
</div>
|
|
{question
|
|
.responsible
|
|
.clone()
|
|
.map(|r| {
|
|
view! { <ResponsibleNote responsible=r/> }
|
|
})}
|
|
<QuestionNav current_id=question_id/>
|
|
}
|
|
.into_any()
|
|
}
|
|
|
|
/// Every other question the visitor currently qualifies for, as a
|
|
/// small nav - how a concern reaches the people it speaks to without
|
|
/// claiming front-page space (an owner also sees the gated desks here).
|
|
#[component]
|
|
fn QuestionNav(current_id: String) -> impl IntoView {
|
|
let NavRes(nav) = expect_context();
|
|
view! {
|
|
<Suspense fallback=|| ()>
|
|
{move || {
|
|
let current_id = current_id.clone();
|
|
nav.get().and_then(|res| res.ok()).map(|items| {
|
|
let others: Vec<(String, String)> = items
|
|
.into_iter()
|
|
.filter(|(id, _)| *id != current_id)
|
|
.collect();
|
|
(!others.is_empty()).then(|| view! {
|
|
<nav class="question-nav">
|
|
<span class="question-nav-lead">{move || t(&lang().get(), "also_worth_asking")}</span>
|
|
<For
|
|
each=move || others.clone()
|
|
key=|(id, _)| id.clone()
|
|
children=|(id, name): (String, String)| {
|
|
view! { <a href=id>{name}</a> }.into_any()
|
|
}
|
|
/>
|
|
</nav>
|
|
})
|
|
})
|
|
}}
|
|
</Suspense>
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
#[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) -> 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(); }});"
|
|
)
|
|
});
|
|
|
|
view! {
|
|
<header class="hero" class:hero-module=has_module>
|
|
<Announcements current_id=current_id/>
|
|
{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>
|
|
}
|
|
})}
|
|
<div class="hero-copy">
|
|
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
|
<img src=wordmark alt=site_title style=wordmark_style/>
|
|
</a>
|
|
<h1>{title}</h1>
|
|
<p inner_html=render_inline_markdown(&description)></p>
|
|
</div>
|
|
</header>
|
|
}
|
|
}
|
|
|
|
/// An alternative's banner imagery: nothing, a plain image, or - for
|
|
/// several urls - a Swiper Element card deck. The `swiper-container`/
|
|
/// `swiper-slide` custom elements render inert server-side and upgrade
|
|
/// once the vendored bundle registers them (loaded per-page by
|
|
/// `QuestionView` only when some alternative actually needs it).
|
|
#[component]
|
|
fn AltImages(images: Vec<String>) -> impl IntoView {
|
|
match images.len() {
|
|
0 => ().into_any(),
|
|
1 => view! { <img class="alt-image" src=images[0].clone() alt="" /> }.into_any(),
|
|
_ => view! {
|
|
<swiper-container class="alt-image-deck" effect="cards" grab-cursor="true">
|
|
<For
|
|
each=move || images.clone()
|
|
key=|src| src.clone()
|
|
children=|src: String| view! {
|
|
<swiper-slide>
|
|
<img class="alt-image" src=src alt="" />
|
|
</swiper-slide>
|
|
}.into_any()
|
|
/>
|
|
</swiper-container>
|
|
}
|
|
.into_any(),
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn AlternativeCard(
|
|
question_id: String,
|
|
alternative: Alternative,
|
|
parent_hash: Option<String>,
|
|
query_email: Option<String>,
|
|
/// 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());
|
|
// Nothing to submit, nothing to confirm, nothing to record: the
|
|
// alternative is a gateway to another question, and its button is
|
|
// just a link (a form submit would be a click that does nothing).
|
|
let is_gateway = has_action
|
|
&& !has_requirements
|
|
&& alternative.record_as.is_none()
|
|
&& alternative
|
|
.features
|
|
.iter()
|
|
.all(|f| f.resource.as_ref().is_none_or(|r| r.transitions.is_empty()));
|
|
|
|
// Shared across every resource feature on this alternative: which
|
|
// row(s) have a transition selected but not yet confirmed, keyed by
|
|
// (feature_name, item_id) so a batch can span more than one
|
|
// resource. One button confirms every selection at once (see
|
|
// `on_submit` below) instead of a Confirm button per row.
|
|
let pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>> =
|
|
RwSignal::new(std::collections::HashMap::new());
|
|
let transition_batch = ServerAction::<TransitionAnswers>::new();
|
|
|
|
Effect::new(move |_| {
|
|
if let Some(Ok(())) = transition_batch.value().get() {
|
|
pending_transitions.set(std::collections::HashMap::new());
|
|
}
|
|
});
|
|
|
|
// Text fields live in field_map; file fields (element refs, read at
|
|
// submit) and select fields (a set of ids, plus which are multiple)
|
|
// each need their own shape.
|
|
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
|
|
std::collections::HashMap::new();
|
|
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
|
|
std::collections::HashMap::new();
|
|
let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> =
|
|
std::collections::HashMap::new();
|
|
let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
|
|
// Gesture fields share field_map (their hidden input carries a
|
|
// JSON string), but submit re-parses them so the stored answer
|
|
// holds a real {points, key} object, not a double-encoded string.
|
|
let mut gesture_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
for feature in &alternative.features {
|
|
for req in &feature.requirements {
|
|
if req.kind == "file" {
|
|
file_refs.entry(req.name.clone()).or_default();
|
|
} else if req.kind == "select" {
|
|
select_field_map
|
|
.entry(req.name.clone())
|
|
.or_insert_with(|| RwSignal::new(Vec::new()));
|
|
select_multi.insert(req.name.clone(), req.multiple);
|
|
} else {
|
|
// 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
// DOM ids are prefixed with question AND alternative - two
|
|
// alternatives on one page can both ask for "email", and duplicate
|
|
// ids would make <label for=...> pick the wrong input.
|
|
let field_prefix = format!(
|
|
"{question_id}-{}",
|
|
alternative.name.to_lowercase().replace(' ', "-")
|
|
);
|
|
|
|
let submit = ServerAction::<SubmitAnswer>::new();
|
|
let navigate = use_navigate();
|
|
|
|
let question_id_for_submit = question_id.clone();
|
|
let alt_name_for_submit = alternative.name.clone();
|
|
let parent_hash_for_submit = parent_hash.clone();
|
|
let field_map_for_submit = field_map.clone();
|
|
let file_refs_for_submit = file_refs.clone();
|
|
let select_field_map_for_submit = select_field_map.clone();
|
|
let select_multi_for_submit = select_multi.clone();
|
|
let gesture_fields_for_submit = gesture_fields.clone();
|
|
let question_id_for_transition = question_id.clone();
|
|
let alt_name_for_transition = alternative.name.clone();
|
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
|
ev.prevent_default();
|
|
|
|
let pending = pending_transitions.get_untracked();
|
|
if !pending.is_empty() {
|
|
let items = pending
|
|
.into_iter()
|
|
.map(|((feature_name, item_id), to)| TransitionItem { feature_name, item_id, to })
|
|
.collect();
|
|
transition_batch.dispatch(TransitionAnswers {
|
|
question_id: question_id_for_transition.clone(),
|
|
alternative: alt_name_for_transition.clone(),
|
|
items,
|
|
});
|
|
}
|
|
|
|
if !has_requirements {
|
|
return;
|
|
}
|
|
|
|
let field_map_for_submit = field_map_for_submit.clone();
|
|
let file_refs_for_submit = file_refs_for_submit.clone();
|
|
let select_field_map_for_submit = select_field_map_for_submit.clone();
|
|
let select_multi_for_submit = select_multi_for_submit.clone();
|
|
let gesture_fields_for_submit = gesture_fields_for_submit.clone();
|
|
let question_id_for_submit = question_id_for_submit.clone();
|
|
let alt_name_for_submit = alt_name_for_submit.clone();
|
|
let parent_hash_for_submit = parent_hash_for_submit.clone();
|
|
leptos::task::spawn_local(async move {
|
|
let mut map = serde_json::Map::new();
|
|
for (name, sig) in field_map_for_submit.iter() {
|
|
let value = if gesture_fields_for_submit.contains(name) {
|
|
// The widget's hidden input holds {points, key} as
|
|
// JSON; store the object itself (empty/never-drawn
|
|
// becomes null, not "").
|
|
serde_json::from_str(&sig.get()).unwrap_or(serde_json::Value::Null)
|
|
} else {
|
|
serde_json::Value::String(sig.get())
|
|
};
|
|
map.insert(name.clone(), value);
|
|
}
|
|
for (name, sig) in select_field_map_for_submit.iter() {
|
|
let ids = sig.get();
|
|
let multiple = select_multi_for_submit.get(name).copied().unwrap_or(false);
|
|
let value = if multiple {
|
|
serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect())
|
|
} else {
|
|
serde_json::Value::String(ids.into_iter().next().unwrap_or_default())
|
|
};
|
|
map.insert(name.clone(), value);
|
|
}
|
|
// Only used under hydrate (file uploads are a browser-only
|
|
// concern); referenced unconditionally so an `ssr` build
|
|
// doesn't warn about it going unused.
|
|
let _ = &file_refs_for_submit;
|
|
|
|
#[cfg(feature = "hydrate")]
|
|
for (name, file_ref) in file_refs_for_submit.iter() {
|
|
let Some(input) = file_ref.get_untracked() else {
|
|
continue;
|
|
};
|
|
let Some(files) = input.files() else { continue };
|
|
let Some(file) = files.get(0) else { continue };
|
|
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
|
|
Ok(key) => {
|
|
map.insert(name.clone(), serde_json::Value::String(key));
|
|
}
|
|
Err(e) => {
|
|
leptos::logging::error!("upload of {name} failed: {e:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
let responses_json =
|
|
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default();
|
|
submit.dispatch(SubmitAnswer {
|
|
question_id: question_id_for_submit,
|
|
alternative: alt_name_for_submit,
|
|
parent_hash: parent_hash_for_submit,
|
|
responses_json,
|
|
});
|
|
});
|
|
};
|
|
|
|
Effect::new(move |_| {
|
|
if let Some(Ok(result)) = submit.value().get() {
|
|
if let Some(next) = result.next {
|
|
navigate(
|
|
&format!("{next}?chain={}", result.chain_hash),
|
|
NavigateOptions::default(),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
let button_label = alternative
|
|
.consequence
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| 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()
|
|
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);
|
|
return view! {
|
|
<label class="field" for=label_for>
|
|
{label_text}
|
|
<input
|
|
id=field_id
|
|
type="file"
|
|
accept=req.accept.clone().unwrap_or_default()
|
|
multiple=req.multiple
|
|
required=!req.optional
|
|
node_ref=file_ref
|
|
/>
|
|
</label>
|
|
}
|
|
.into_any();
|
|
}
|
|
|
|
if req.kind == "gesture" {
|
|
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
|
|
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
|
|
|
|
// A div, not a label: a label would forward
|
|
// every click to the hidden input, and this
|
|
// field is drawn on, not clicked into.
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
let relay = req.relay.clone().unwrap_or_default();
|
|
// Unlike prosekit's fire-and-forget mount,
|
|
// the widget may own a live WebSocket -
|
|
// yes.js's StoredValue + on_cleanup pattern
|
|
// closes it on SPA navigation.
|
|
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> =
|
|
StoredValue::new_local(None);
|
|
Effect::new(move |_| {
|
|
let (Some(container), Some(hidden)) =
|
|
(container_ref.get(), hidden_ref.get())
|
|
else {
|
|
return;
|
|
};
|
|
if widget.with_value(|w| w.is_some()) {
|
|
return;
|
|
}
|
|
widget.set_value(Some(gesture::mount_gesture(
|
|
&container, &hidden, &relay,
|
|
)));
|
|
});
|
|
on_cleanup(move || {
|
|
widget.update_value(|opt| {
|
|
if let Some(w) = opt.take() {
|
|
w.stop();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return view! {
|
|
<div class="field">
|
|
{label_text}
|
|
<input
|
|
id=field_id
|
|
type="hidden"
|
|
node_ref=hidden_ref
|
|
prop:value=move || sig.get()
|
|
on:input=move |ev| sig.set(event_target_value(&ev))
|
|
/>
|
|
<div class="gesture-wrap" node_ref=container_ref></div>
|
|
</div>
|
|
}
|
|
.into_any();
|
|
}
|
|
|
|
if req.kind == "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();
|
|
}
|
|
|
|
// `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 {
|
|
let href = alternative.action.clone().unwrap_or_default();
|
|
view! {
|
|
<a class="alt-submit" href=href>{button_label.clone()}</a>
|
|
}
|
|
.into_any()
|
|
} else {
|
|
view! {
|
|
<form on:submit=on_submit>
|
|
<button
|
|
type="submit"
|
|
class="alt-submit"
|
|
disabled=move || {
|
|
(!has_requirements && pending_transitions.get().is_empty())
|
|
|| submit.pending().get()
|
|
|| transition_batch.pending().get()
|
|
}
|
|
>
|
|
{button_label.clone()}
|
|
</button>
|
|
</form>
|
|
}
|
|
.into_any()
|
|
}}
|
|
</div>
|
|
}
|
|
})}
|
|
</section>
|
|
}
|
|
.into_any()
|
|
}
|
|
|
|
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
|
|
/// Fully generic: it has no idea what "/review" or "applicants" are - it
|
|
/// just fetches `question_id`/`feature_name`'s data and renders it
|
|
/// according to its own shape (see `ResourceValue`), with one button
|
|
/// per `transitions` entry when the data looks like a list of answers.
|
|
#[component]
|
|
fn ResourceFeature(
|
|
question_id: String,
|
|
alternative: String,
|
|
feature_name: String,
|
|
transitions: Vec<Transition>,
|
|
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();
|
|
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
|
|
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>,
|
|
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;
|
|
answers.sort_by(|a, b| {
|
|
let open = |s: &str| s != crate::answers::OPEN_STATE;
|
|
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
|
|
});
|
|
return view! {
|
|
<div class="answer-list">
|
|
<For
|
|
each=move || answers.clone()
|
|
key=|a| a.id.clone()
|
|
children={
|
|
let feature_name = feature_name.clone();
|
|
let transitions = transitions.clone();
|
|
move |answer: Answer| {
|
|
view! {
|
|
<AnswerRow
|
|
feature_name=feature_name.clone()
|
|
transitions=transitions.clone()
|
|
answer=answer
|
|
pending_transitions=pending_transitions
|
|
transition_batch=transition_batch
|
|
/>
|
|
}
|
|
.into_any()
|
|
}
|
|
}
|
|
/>
|
|
</div>
|
|
}
|
|
.into_any();
|
|
}
|
|
// 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>
|
|
}
|
|
}
|
|
|
|
#[component]
|
|
fn AnswerRow(
|
|
feature_name: String,
|
|
transitions: Vec<Transition>,
|
|
answer: Answer,
|
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
|
transition_batch: ServerAction<TransitionAnswers>,
|
|
) -> impl IntoView {
|
|
// Only the transitions legal from this row's current state - two
|
|
// declared transitions may share a target (open -> declined and
|
|
// in_dialogue -> declined), each rendered only on rows actually in
|
|
// its `from` state.
|
|
let available: Vec<Transition> = transitions
|
|
.iter()
|
|
.filter(|t| t.from == answer.state)
|
|
.cloned()
|
|
.collect();
|
|
// "Decided" (dimmed) means this row has no legal move here. A
|
|
// read-only listing (no transitions declared at all) keeps the old
|
|
// rule - dim anything past `open` - since "no legal move" would
|
|
// dim every row indiscriminately.
|
|
let decided = if transitions.is_empty() {
|
|
answer.state != crate::answers::OPEN_STATE
|
|
} else {
|
|
available.is_empty()
|
|
};
|
|
let responses = answer
|
|
.responses
|
|
.as_object()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let submitted_ms = answer.submitted_ms;
|
|
let notes = answer.notes.clone();
|
|
let state = answer.state.clone();
|
|
let item_id = answer.id.clone();
|
|
let key = (feature_name.clone(), item_id.clone());
|
|
|
|
view! {
|
|
<article class="answer-row" class:decided=move || decided>
|
|
<div class="answer-fields">
|
|
<For
|
|
each={
|
|
let responses = responses.clone();
|
|
move || responses.clone().into_iter().collect::<Vec<_>>()
|
|
}
|
|
key=|(k, _)| k.clone()
|
|
children=move |(key, value)| {
|
|
// 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());
|
|
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>
|
|
// 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>
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
use crate::content::{path_matches, resolve_question};
|
|
use crate::server::AppState;
|
|
let state = expect_context::<AppState>();
|
|
let questions = state.questions.load();
|
|
let Some(question) = resolve_question(&questions, &path) else {
|
|
return Ok(None);
|
|
};
|
|
let mut chain_gate = None;
|
|
if let Some(target) = &question.requires_chain {
|
|
// The claimed lineage must verifiably end at the required
|
|
// question - an unindexed or absent hash reads as unverified,
|
|
// and the gate stays shut. Dynamic targets match any concrete
|
|
// answer of theirs.
|
|
let verified = match &chain {
|
|
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
|
|
.await
|
|
.is_some_and(|node| {
|
|
node.question_id == *target
|
|
|| path_matches(target, &node.question_id).is_some()
|
|
}),
|
|
None => false,
|
|
};
|
|
if !verified {
|
|
let name = questions
|
|
.get(target)
|
|
.map(|q| q.name.clone())
|
|
.unwrap_or_default();
|
|
chain_gate = Some((target.clone(), name));
|
|
}
|
|
}
|
|
Ok(Some(Page {
|
|
question,
|
|
chain_gate,
|
|
}))
|
|
}
|
|
|
|
/// The content repo's branding (`site.yaml`) - title, wordmark, hero
|
|
/// kind. Defaults (= the historical uhhm look) when the repo declares
|
|
/// nothing.
|
|
#[server(endpoint = "get_site")]
|
|
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
|
|
use crate::server::AppState;
|
|
let state = expect_context::<AppState>();
|
|
let mut site = state.site.load().as_ref().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();
|
|
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> {
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
|
use crate::content::{earned_followups, unlocked_by, Question};
|
|
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 questions = state.questions.load();
|
|
// 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, ¤t, &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
|
|
.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())
|
|
.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,
|
|
}
|
|
|
|
#[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 question = crate::content::resolve_question(&state.questions.load(), &question_id)
|
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
|
|
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
|
let user = session
|
|
.get::<User>(SESSION_USER_KEY)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
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}")))?;
|
|
|
|
// 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 kanidm = crate::people::Kanidm::from_env()
|
|
.ok_or_else(|| ServerFnError::new("invites are not configured on this instance (KANIDM_API_TOKEN)"))?;
|
|
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
|
let (name, email) = match (field("name"), field("email")) {
|
|
(Some(n), Some(e)) => (n, e),
|
|
_ => return Err(ServerFnError::new("an invite needs a name and an email")),
|
|
};
|
|
let invited = kanidm
|
|
.invite(&name, &email, &invite.group)
|
|
.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());
|
|
}
|
|
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,
|
|
},
|
|
)
|
|
.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,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!("failed to store answer in {bucket}: {e}");
|
|
}
|
|
// 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).await;
|
|
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"))
|
|
}
|