use leptos::prelude::*;
use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
path, NavigateOptions,
};
use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::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.
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
}
}
#[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 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);
view! {
{move || {
site.get()
.map(|res| {
let title = res
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| SITE_NAME.to_string());
view! { }
})
}}
}
}
/// 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` context is claimed by whoever provides
/// that T last.
#[derive(Clone, Copy)]
struct QuestionRes(Resource, ServerFnError>>);
#[derive(Clone, Copy)]
struct UserRes(Resource, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource, ServerFnError>>);
#[derive(Clone, Copy)]
struct AnnounceRes(Resource, 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,
}
/// 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 || chain.get().is_some(), list_qualifying_questions);
let announcements = Resource::new(|| (), |_| list_announcements());
provide_context(QuestionRes(question));
provide_context(UserRes(user));
provide_context(NavRes(nav));
provide_context(AnnounceRes(announcements));
view! {
}>
}
}
#[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::>>();
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();
let has_chain = parent_hash.is_some();
// 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."
// A gate is never a dead end: the same nav as every page.
}
.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);
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 _ = has_chain; // keyed into the shared resource by PortalShell
let NavRes(nav) = expect_context();
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! {
})
})
}}
}
}
/// 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! {
{move || {
let current_id = current_id.clone();
announcements.get().and_then(|res| res.ok()).map(|items| {
(!items.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."
}
}
// 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")]
#[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);
}
}
#[cfg(feature = "hydrate")]
mod gesture {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/gesture.js")]
extern "C" {
pub type GestureWidget;
#[wasm_bindgen(js_name = mountGesture)]
pub fn mount_gesture(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &GestureWidget);
}
}
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig, current_id: String) -> impl IntoView {
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
// `hero: {kind: module, module: hero.js}` names a JavaScript module
// (served same-origin at /site/) 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();
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());
let piece_ref: NodeRef = 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