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 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! {
{move || wordmark_origin.get().map(|href| view! { })}
{move || wordmark_href.get().map(|href| view! { })}
{move || custom_css.get().map(|href| view! { })}
{move || favicon.get().map(|href| view! { })}
{move || {
site.get()
.map(|res| {
let title = res
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| SITE_NAME.to_string());
view! { }
})
}}
}
}
/// The site's chrome language as a context signal - see App.
#[derive(Clone, Copy)]
pub struct Lang(pub Memo);
/// The current chrome language, for `t()` calls inside views.
fn lang() -> Memo {
expect_context::().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` 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 || (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! {
}>
}
}
#[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! {
// 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.
}
}>
{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! {
// 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! {
}
.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! { })}
{needs_map
.then(|| {
view! {
}
})}
// 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! {
{name}
}
})}
// 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! { })}
{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).
/// 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.
{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! {
}
})
}}
{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.
/// 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::::new();
let text = NodeRef::::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! {
})
}}
}
}
#[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! {
{move || t(&lang().get(), "asked_by")} {responsible.name.clone()} " — "
{move || t(&lang().get(), "contact_them")}
{move || t(&lang().get(), "if_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);
}
}
// 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,
#[prop(optional_no_strip)] slideshow: Option,
) -> 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();
// 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 = 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