use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
path, NavigateOptions,
};
use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition};
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.
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
}>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let location = use_location();
let query = use_query_map();
let path = Memo::new(move |_| {
let p = location.pathname.get();
if p.is_empty() {
"/".to_string()
} else {
p
}
});
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
let question = Resource::new(
move || (path.get(), parent_hash.get()),
|(path, chain)| get_question(path, chain),
);
let user = Resource::new(|| (), |_| current_user());
let site = Resource::new(|| (), |_| get_site());
view! {
// The content-declared site title lives OUTSIDE the question
// Suspense: tied to the per-page resource it unmounted on every
// SPA navigation and lost the leptos_meta race to App's
// compile-time SITE_NAME fallback (redoal.com flashing to
// "uhhm" on nav - and staying there). Out here it mounts once
// and covers every branch, NotFound included.
{move || {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.title)
.map(|t| view! { })
}}
"loading…"
}
}>
{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! {
}
.into_any()
}
_ => view! { }.into_any(),
})
}}
}
}
#[component]
fn QuestionView(
question: Question,
chain_gate: Option<(String, String)>,
parent_hash: Option,
query_email: Option,
user: Option,
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! {
"This page follows from an answer you don't seem to carry yet."
}
.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! {
{if signed_in {
view! {
"This part of the site is for organizational owners — sign in with that account to take a look."
}
.into_any();
}
// Swiper Element only registers its custom elements when its
// (vendored, ~180KB) script runs - loaded once per page, and only
// on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
let has_chain = parent_hash.is_some();
view! {
{needs_swiper.then(|| view! { })}
{question
.responsible
.clone()
.map(|r| {
view! { }
})}
}
.into_any()
}
/// Every other question the visitor currently qualifies for, as a
/// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here).
#[component]
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
let nav = Resource::new(move || has_chain, list_qualifying_questions);
view! {
{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! {
})
})
}}
}
}
/// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` -
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
#[component]
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible
.contact
.split_once('@')
.map(|(u, d)| (u.to_string(), d.to_string()))
.unwrap_or((responsible.contact.clone(), String::new()));
// Zero captures (reads data-user/data-domain off the target element
// itself, not from the closure's environment) - Copy, so the same
// value works for both `on:` attributes below without cloning.
let assemble = move |ev: leptos::ev::MouseEvent| {
#[cfg(feature = "hydrate")]
{
let target = event_target::(&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! {
"Asked by " {responsible.name.clone()} " — "
"contact them"
" if you get stuck."
}
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
// raw_module (a runtime URL, not a bundled snippet) on purpose:
// the file lives in public/ so it's served at the stable /yes.js
// - the same URL the hero's inline early-mount script imports.
// A bundled snippet would sit under a per-build hashed
// /pkg/snippets/ path the inline script couldn't know.
#[wasm_bindgen(raw_module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
// Same idiomatic-typed-module approach as `yes` above, for the
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
// project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative
// to the crate root, not the `public/` static-asset dir, same as
// `yes.js` below). `mount_editor` hands the editor a container node
// plus the paired
// hidden `` 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")]
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(js_name = mountGestureHero)]
pub fn mount_gesture_hero(
container: &web_sys::HtmlDivElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &GestureWidget);
}
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
// Only the landing page gets a full interactive piece - it's the
// one page a hero is actually "the" hero for; other pages
// (thank-you pages, /review) get the plain dark header below.
// WHICH piece is the content repo's call (`site.yaml`'s
// hero.kind): the YES canvas (default - uhhm's look), a redoal
// gesture canvas, or nothing.
let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() };
let show_yes = hero_kind == "yes";
let show_gesture = hero_kind == "gesture";
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
// Starts the YES animation once the canvas elements exist, and
// explicitly stops the requestAnimationFrame loop on unmount - the
// original page-owning script never needed this since navigating
// away meant a full document unload, which doesn't happen in an
// SPA.
let raster_ref: NodeRef = NodeRef::new();
let gesture_ref: NodeRef = NodeRef::new();
#[cfg(feature = "hydrate")]
if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm),
// which a JS-backed value never is - `StoredValue`'s `LocalStorage`
// variant is the established way around this (same pattern
// cnats' WebRTC call state uses for its own non-Send peer map).
let instance: StoredValue