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, 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! { // Two prefers-color-scheme-scoped links, not one SVG with // an embedded @media query - confirmed via real-device // testing (see infrastructure's gitea-head.tmpl) that an // SVG's own internal @media doesn't reliably re-evaluate // once loaded via / on Safari/iOS. // 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.) // Server-side, so it can read hash.txt and link the // content-hashed stylesheet (hash-files = true). // Before the router, so it sees a link's click first. } } #[component] pub fn App() -> impl IntoView { provide_meta_context(); // ONE site resource and ONE 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.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() 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> // 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() .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() } /// 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 /// 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")) } #[component] fn QuestionNav(current_id: String) -> impl IntoView { let NavRes(nav) = expect_context(); let location = leptos_router::hooks::use_location(); 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. <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> <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. /// 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(); // In the page's ink (`wordmark_ink`): a span the file masks, sized // by the file's own proportions through an invisible copy of it, // and named for screen readers as the image would have been. let ink = site.wordmark_ink; let mark = move || { if ink { let mask = format!("--wordmark-src:url(\"{}\")", wordmark.replace('"', "%22")); view! { <a class="wordmark wordmark-ink" href="/" aria-label=site_title.clone()> <span class="wordmark-ink-mark" style=mask> <img src=wordmark.clone() alt="" aria-hidden="true" style=wordmark_style.clone()/> </span> </a> } .into_any() } else { view! { <a class="wordmark" class:wordmark-house=house_wordmark href="/"> <img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/> </a> } .into_any() } }; let top = view! { <div class="hero-top">{mark()}{session}</div> }; let (bar_top, copy_top) = if has_slides { (Some(top), None) } else { (None, Some(top)) }; view! { <header class="hero" class:hero-module=has_module class:hero-slides=has_slides> <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> } })} // 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. // Who is here sits on the wordmark's own line, not on a row // of its own above it. {bar_top.map(|top| view! { <div class="hero-bar">{top}</div> })} {slideshow.map(|show| view! { <SlideStrip show=show/> })} <div class="hero-copy"> {copy_top} <h1>{title}</h1> <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()); // 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" { // 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)); } } } // 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 }; // 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:?}"); 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() 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(); } // `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()) || sending.get() || 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> }) }} </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>, link: Option<RowLink>, bucket: Option<String>, empty: Option<String>, pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>, transition_batch: ServerAction<TransitionAnswers>, ) -> impl IntoView { // A desk shows the newest few finished records; "show older" asks // for more (see `resource::cap_finished`). let finished_shown = RwSignal::new(crate::resource::FINISHED_SHOWN); 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(), finished_shown.get()) }, // 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, shown)| { let mut params = std::collections::HashMap::new(); if shown != crate::resource::FINISHED_SHOWN { params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string()); } get_resource(q, a, f, params) }, ); // 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 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 link=link bucket=bucket empty=empty value=value pending_transitions=pending_transitions transition_batch=transition_batch finished_shown=finished_shown /> } .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>, 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>, finished_shown: RwSignal<usize>, ) -> 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) }); // The desk sent one finished record more than it shows, when // there are older ones: drop the oldest and offer the rest. let shown = finished_shown.get_untracked(); let older = answers.iter().filter(|a| a.finished).count() > shown; if older { if let Some(oldest) = answers .iter() .enumerate() .filter(|(_, a)| a.finished) .min_by_key(|(_, a)| a.decided_ms.unwrap_or(a.submitted_ms)) .map(|(i, _)| i) { answers.remove(oldest); } } let show_older = older.then(|| { view! { <button type="button" class="show-older" on:click=move |_| finished_shown.update(|n| *n += crate::resource::FINISHED_SHOWN) > {move || t(&lang().get(), "show_older")} </button> } }); 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(); let link = link.clone(); let bucket = bucket.clone(); move |answer: Answer| { view! { <AnswerRow feature_name=feature_name.clone() transitions=transitions.clone() link=link.clone() bucket=bucket.clone() answer=answer pending_transitions=pending_transitions transition_batch=transition_batch /> } .into_any() } } /> {show_older} </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()) } /// 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>, 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()); 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> // 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> // What the person wrote, as they wrote it: markdown, on // the stricter rules for text that is not the site's. {rest .into_iter() .map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> }) .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> { 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); }; 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> { 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 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, ¤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, } /// 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"))?; 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 }; // 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 mut invited = people .invite(&state, &name, &who, &invite.group, &principal_name(&principal)) .await .map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?; // This invite's own words, and the form they draw on: kept with // the invite, so sending it again says the same thing. invited.mail = invite.mail.clone(); if invited.mail.is_some() { invited.fields = responses.as_object().cloned().unwrap_or_default(); } 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; 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")) } #[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; use crate::content::Responsible; 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) } } 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() { 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::*; #[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); } }